diff --git a/.env.example b/.env.example index 9ead4ef0..3d300394 100644 --- a/.env.example +++ b/.env.example @@ -549,6 +549,26 @@ DATASETS_STORAGE_KIND=memory DATASETS_LOCAL_DIR=./data/datasets +# ─── Solver reputation (issue #444) ────────────────────────────────────────── +# Weights for each reputation sub-component. Sum must be exactly 1.0 in production; +# dev/test renormalises any valid positive set so local experimentation doesn't +# prevent boot. +REP_WEIGHT_FILL_RATE=0.35 +REP_WEIGHT_LATENCY=0.15 +REP_WEIGHT_SLASHES=0.25 +REP_WEIGHT_QUOTE_HONOUR=0.15 +REP_WEIGHT_VOLUME=0.10 +# Shared exponential-decay half-life (seconds) for all events. +# Default: 30 days — scores dominated by the last ~1 month of activity. +REP_DECAY_HALFLIFE_SECONDS=2592000 +# Beta-distribution priors for the Bayesian cold-start lower bound on fill rate. +# (α=4, β=1) ≈ prior mean 80%, 2.5% lower bound ~37%. +REP_BAYES_ALPHA=4 +REP_BAYES_BETA=1 +# Volume-component knee in USD-equivalent notional; $100k default. +REP_VOLUME_LAMBDA_USD=100000 +# How many trailing days of daily snapshots the /reputation endpoint exposes (1..365). +REP_HISTORY_WINDOW_DAYS=30 # ─── Read replicas (#411) ───────────────────────────────────────────────────── # Comma-separated read-replica Postgres connection strings. # Leave blank to route all reads to the primary. diff --git a/.eslintrc.json b/.eslintrc.json index 09d27129..233714eb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -52,7 +52,7 @@ "ignorePatterns": ["dist", "node_modules"], "overrides": [ { - "files": ["scripts/**/*.ts", "tools/**/*.ts"], + "files": ["scripts/**/*.ts", "tools/**/*.ts", "test/chaos/**/*.ts"], "rules": { "no-restricted-syntax": "off" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69de29b..e53cbef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -0,0 +1,1236 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel in-progress runs for the same branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + secrets-scan: + name: Secrets Scanning (Gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + - name: Gitleaks Scan + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + vulnerability-scan: + name: Dependency Vulnerability Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + - run: npm audit --audit-level=high + + license-scan: + name: Dependency License Compliance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + # Permissive-only allow-list, reconciled with the project's own + # MIT/Apache-2.0 license (issue #107). Fails the build on any + # dependency (direct or transitive) carrying a copyleft or + # unrecognized/unlicensed license. + - name: Check dependency licenses + run: | + npx license-checker --production \ + --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;CC0-1.0;Unlicense' \ + --excludePrivatePackages + + commitlint: + name: Commit Message Lint + runs-on: ubuntu-latest + # Only meaningful on PRs — on direct pushes to main we skip this check + # since squash merges are handled by the branch protection rule. + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + # Fetch enough history to validate all commits in the PR. + fetch-depth: 0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Lint every commit in the PR from the merge base to HEAD. + - name: Lint commit messages + run: | + npx commitlint \ + --from ${{ github.event.pull_request.base.sha }} \ + --to ${{ github.event.pull_request.head.sha }} \ + --verbose + + backend: + # The en dash in the name is not cosmetic: branch protection stores the + # exact check name as a string, so replacing it with a hyphen renames a + # required check and blocks every PR until an admin edits the rule. + name: Backend (Nest) – Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + + # Spin up a PostgreSQL service container so Prisma can connect during the + # migrate step (migrate deploy requires a live DB). + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + # Wait until postgres is healthy before the steps run. + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + # ── Node / npm cache (#130) ────────────────────────────────────────── + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + # ── Env drift check ──────────────────────────────────────────────────── + # Fast, early-failing check that env.validation.ts, .env*.example, and + # configuration.ts's process.env reads all agree on the same variable set. + - name: Check env var drift + run: npx tsx scripts/check-env-drift.ts + + # ── Prisma ────────────────────────────────────────────────────────────── + - name: Validate Prisma schema + run: npm run db:validate + + # `npm ci` deletes node_modules, so the generated client has to be cached + # *after* the install, not before. The key includes package-lock.json as + # well as the schema: the client is also regenerated when the Prisma + # version moves, and a client generated by a different version fails at + # runtime rather than at generate time. + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + # Apply all pending migrations to the CI database so the schema stays in + # sync and any broken migration SQL is caught before merge. + - name: Run database migrations + run: npm run db:migrate:prod + + # ── Build & static checks ────────────────────────────────────────────── + - name: Lint + run: npm run lint + + - name: Type-check + run: npm run typecheck + + # dist/ is keyed on everything tsc reads, so a hit means the restored + # output is what this commit's build would produce anyway. There is + # deliberately no restore-keys prefix fallback: a partially-restored + # dist/ from a different commit is exactly the stale-artifact bug this + # cache must not be able to introduce. + - name: Restore cached build output + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: dist + key: dist-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('src/**/*.ts', 'package.json', 'tsconfig.json', 'nest-cli.json') }} + + - name: Build + run: npm run build + + # Unit and E2E tests deliberately do NOT run here. They are sharded into + # the `unit-tests`, `e2e-tests` and `coverage` jobs below (issue #486) so + # the suite fans out across runners instead of serialising behind lint + # and build on both Node versions. This job's name is a required status + # check, so it stays exactly as it was and keeps meaning "the backend + # compiles and passes static analysis on Node X". + + dast: + name: DAST API scan (OWASP ZAP) + runs-on: ubuntu-latest + needs: backend + timeout-minutes: 15 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + NODE_ENV: test + PORT: 4000 + CORS_ORIGIN: "*" + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + - name: Install dependencies + run: npm ci + - name: Generate Prisma client + run: npm run db:generate + - name: Run database migrations + run: npm run db:migrate:prod + - name: Build backend + run: npm run build + - name: Start backend for ZAP scan + run: >- + npm run start > /tmp/vortex-backend.log 2>&1 & + - name: Wait for backend to become healthy + run: | + for i in $(seq 1 60); do + if curl -fsS http://localhost:${PORT}/health >/dev/null; then + echo "Backend healthy"; exit 0; + fi + sleep 2; + done + echo "Backend never became healthy" >&2 + cat /tmp/vortex-backend.log || true + exit 1 + - name: Run OWASP ZAP API scan + run: | + docker run --rm \ + --network host \ + -v "$PWD/.github/zap:/zap/wrk:ro" \ + -v "$PWD:/zap/output:rw" \ + owasp/zap2docker-stable \ + zap-api-scan.py \ + -t http://localhost:${PORT}/docs-json \ + -f openapi \ + -m 10 \ + -J /zap/output/zap-report.json \ + -r /zap/output/zap-report.html \ + -x /zap/output/zap-report.xml \ + \ + || true + - name: Fail on medium+ findings + run: | + python - <<'PY' + import json, os, sys + path = "zap-report.json" + if not os.path.exists(path): + print("No ZAP report generated; failing the job.") + sys.exit(1) + data = json.load(open(path, encoding="utf-8")) + findings = [] + for site in data.get("site", []): + for alert in site.get("alerts", []): + risk = int(alert.get("riskcode", "0")) + if risk >= 2: + findings.append({ + "name": alert.get("name"), + "risk": alert.get("riskdesc", "Unknown"), + "url": alert.get("url", "unknown"), + }) + rules = json.load(open("zap-report.json", encoding="utf-8")) if False else None + if findings: + print("Medium+ ZAP findings detected:") + for item in findings: + print(f"- {item['name']} ({item['risk']}) @ {item['url']}") + sys.exit(1) + print("No medium+ ZAP findings detected.") + PY + - name: Convert ZAP results to SARIF + if: always() + run: | + python - <<'PY' + import json + import os + src = "zap-report.json" + out = "zap-report.sarif" + if not os.path.exists(src): + print("No ZAP results to convert; writing an empty SARIF file.") + empty = { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "OWASP ZAP", "informationUri": "https://www.zaproxy.org/", "rules": []}}, + "results": [] + }] + } + json.dump(empty, open(out, "w", encoding="utf-8")) + raise SystemExit(0) + data = json.load(open(src, encoding="utf-8")) + results = [] + rules = [] + seen = set() + for site in data.get("site", []): + for alert in site.get("alerts", []): + rule_id = alert.get("pluginid", alert.get("alert", "unknown")) + if rule_id not in seen: + seen.add(rule_id) + rules.append({ + "id": str(rule_id), + "shortDescription": {"text": alert.get("name", "OWASP ZAP alert")}, + "fullDescription": {"text": alert.get("desc", "Alert reported by OWASP ZAP")}, + "defaultConfiguration": {"level": "warning"}, + "helpUri": "https://www.zaproxy.org/docs/alerts/" + }) + level = "warning" + risk = alert.get("riskdesc", "Informational") + if "High" in risk: + level = "error" + elif "Medium" in risk: + level = "warning" + results.append({ + "ruleId": str(rule_id), + "level": level, + "message": {"text": alert.get("name", "OWASP ZAP alert")}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": alert.get("url", "http://localhost")}}}], + "properties": {"issue_confidence": alert.get("confidence", "Medium")} + }) + sarif = { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "OWASP ZAP", "informationUri": "https://www.zaproxy.org/", "rules": rules}}, + "results": results + }] + } + json.dump(sarif, open(out, "w", encoding="utf-8")) + PY + - name: Upload SARIF to GitHub code scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: zap-report.sarif + + # ── Sharded test fan-out (issue #486) ─────────────────────────────────── + # Three jobs replace the old `npm test` + `npm run test:e2e` pair: + # unit-tests -> N Jest shards, each writing its own coverage + flake report + # e2e-tests -> M Jest shards against the migrated Postgres service + # coverage -> merges the shard reports and enforces the 70% gate + # + # fail-fast is off on both matrices on purpose: with it on, the first failing + # shard cancels its siblings, their coverage artifacts are never uploaded, + # and the merge job reports "a shard never wrote its report" instead of the + # real failure. Every shard has to finish so the coverage merge is complete. + + unit-tests: + name: Unit tests (shard ${{ matrix.shard }}/${{ matrix.shard-total }}) + runs-on: ubuntu-latest + needs: backend + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + shard-total: [4] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + # Opts the Postgres repository contract suite in + # (src/intents/prisma-intents.repository.spec.ts — issue #404). + TEST_DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + # Tests run on the lowest supported Node only. Running the same 35 + # spec files on 20 and 22 doubles CI minutes to re-test runtime + # behaviour the build job already proves compiles on both; the + # version-specific risk is a native module, which `npm run build` + # loads on both anyway. + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Same Prisma client cache as the backend job: generate-and-migrate is + # the slowest fixed cost in a test shard, and the key is content-addressed + # so a schema or lockfile change invalidates it on its own. + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + # run-tests.mjs applies test/quarantine.json, writes the shard's + # Istanbul report to its own directory (so shards cannot clobber each + # other), and retries only the failing files to separate a flake from a + # real failure. + - name: Run unit test shard + run: >- + node scripts/ci/run-tests.mjs + --suite=unit + --shard=${{ matrix.shard }}/${{ matrix.shard-total }} + --out-dir=ci-artifacts/unit-s${{ matrix.shard }} + --coverage-dir=coverage-shards/unit-s${{ matrix.shard }} + + - name: Upload shard coverage + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-unit-s${{ matrix.shard }} + path: coverage-shards/unit-s${{ matrix.shard }} + if-no-files-found: warn + + - name: Upload flake report and Jest results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: unit-s${{ matrix.shard }}-logs + path: ci-artifacts/unit-s${{ matrix.shard }} + if-no-files-found: warn + + e2e-tests: + name: E2E tests (shard ${{ matrix.shard }}/${{ matrix.shard-total }}) + runs-on: ubuntu-latest + needs: backend + strategy: + fail-fast: false + matrix: + shard: [1, 2] + shard-total: [2] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Same Prisma client cache as the backend job: generate-and-migrate is + # the slowest fixed cost in a test shard, and the key is content-addressed + # so a schema or lockfile change invalidates it on its own. + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + # Anvil for the EVM deposit-verification integration test (issue #403, + # test/evm/deposit-verifier.anvil.test.ts — skipped when anvil is absent). + - name: Install Foundry (anvil) + uses: foundry-rs/foundry-toolchain@v1 + + # No --coverage-dir here. test/jest-e2e.json has no collectCoverageFrom, + # so its coverage map has different file keys than the unit config's; + # merging the two would trip the shard-consistency check in + # coverage-merge.mjs. The coverage gate is measured on the unit shards, + # which is also where nearly all of the code is exercised. + - name: Run E2E test shard + run: >- + node scripts/ci/run-tests.mjs + --suite=e2e + --shard=${{ matrix.shard }}/${{ matrix.shard-total }} + --out-dir=ci-artifacts/e2e-s${{ matrix.shard }} + + - name: Upload flake report and Jest results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-s${{ matrix.shard }}-logs + path: ci-artifacts/e2e-s${{ matrix.shard }} + if-no-files-found: warn + + # ── E2E against the Postgres-backed intents store (issue #404) ───────────── + # The e2e-tests shards use the default in-memory store. This runs the same + # suite, including the concurrency and POST /intents latency load tests, + # with INTENTS_STORE=postgres. --runInBand: suites share one database, so + # parallel workers would race on table-wide counts (e.g. the stats deltas). + e2e-postgres-store: + name: E2E tests (INTENTS_STORE=postgres) + runs-on: ubuntu-latest + needs: backend + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + INTENTS_STORE: postgres + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + - name: Install Foundry (anvil) + uses: foundry-rs/foundry-toolchain@v1 + + - name: E2E tests + run: npm run test:e2e -- --runInBand + + coverage: + name: Coverage merge and gate + runs-on: ubuntu-latest + needs: [unit-tests, e2e-tests] + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Without merge-multiple, each artifact lands in its own subdirectory of + # the target path, so the four shards arrive as + # coverage-shards/coverage-unit-s1..4/coverage-final.json -- exactly the + # one-directory-deep layout coverage-merge.mjs scans. + - name: Download shard coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: coverage-unit-s* + path: coverage-shards + + - name: Download flake reports + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: "*-logs" + path: ci-artifacts + + # The 70% gate is enforced here, on the union, and never per shard: a + # shard that runs a ninth of the suite cannot meet a global threshold. + # The threshold itself is read from jest.config.js, so this stays a single + # definition rather than a second copy in the workflow. + - name: Merge shard coverage and enforce threshold + run: >- + node scripts/ci/coverage-merge.mjs + --shards=coverage-shards + --out=coverage + --expect-shards=4 + --title="Coverage (merged from 4 unit test shards)" + + - name: Upload merged coverage + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-merged + path: coverage + if-no-files-found: warn + + # Pure Node, no npm ci: the quarantine file is the one piece of test + # infrastructure that can be validated before dependencies are installed, so + # it is also usable as a pre-commit guard (scripts/ci/check-quarantine.mjs). + test-hygiene: + name: Quarantine file hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + + # Fails on a missing owner/issue, a path that no longer exists, a + # duplicate entry, or an entry older than staleAfterDays. + - name: Validate test/quarantine.json + run: node scripts/ci/check-quarantine.mjs + + # ── TLA+ model checking (issue #471) ───────────────────────────────────── + # Bounded TLC run on changes to the spec or the canonical state machine. + formal: + name: TLA+ model check (TLC, bounded) + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request' && + (contains(join(github.event.pull_request.labels.*.name, ','), 'formal') || + true) + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - name: Run TLC (bounded model) + uses: docker://tlaplus/tlaplus:latest + with: + args: tlc -config formal/IntentLifecycle.cfg formal/IntentLifecycle.tla + - name: Upload TLC output + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: tlc-output + path: formal/*.out + + # ── Semgrep custom rules (issue #478) ──────────────────────────────────── + semgrep: + name: Semgrep custom rules + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: returntocorp/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 + with: + config: .semgrep/rules + error: true + - name: Semgrep rule tests + run: | + pip install semgrep + semgrep --test --config .semgrep/rules .semgrep/tests + + # ── Prometheus rules (issue #480) ──────────────────────────────────────── + promtool: + name: promtool check + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - name: Install promtool + run: | + curl -sL https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz | tar xz + sudo mv prometheus-2.53.0.linux-amd64/promtool /usr/local/bin/ + - name: Check rules + run: promtool check rules ops/prometheus/rules/vortex-slo.yml ops/prometheus/rules/vortex-canary.yml + - name: Unit-test rules + run: promtool test rules ops/prometheus/rules/vortex-slo_test.yml ops/prometheus/rules/vortex-canary_test.yml + - name: Check rule file is also a valid Prometheus config fragment + run: promtool check config ops/prometheus/prometheus.yml + + # ── Grafana dashboards (issue #481) ─────────────────────────────────────── + # Two things are checked, neither of which needs a Grafana instance: + # 1. build.mjs --check proves the committed dashboard JSON is exactly what + # the generator produces, so a hand-edited panel cannot drift. + # 2. validate.mjs proves the panels are usable: every recording rule and + # metric a panel names exists, every panel has a legend, description, + # unit and threshold set, no panel reintroduces a multi-day + # histogram_quantile, and every alert links to a dashboard that exists. + # + # Both are plain Node with no dependencies, which is the whole point of + # generating the JSON ourselves instead of adding grafonnet to the toolchain. + grafana: + name: Grafana dashboards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + + - name: Generated dashboards are in sync with build.mjs + run: node ops/grafana/build.mjs --check + + - name: Validate dashboards, panels and alert links + run: node ops/grafana/validate.mjs + + - name: Validate the observability Compose profile + run: docker compose --profile observability config --quiet + + - name: Upload dashboards for review + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: grafana-dashboards + path: ops/grafana/dashboards + + # ── Synthetic canary against the docker-compose stack (issue #496) ──────── + canary: + name: Canary – local lifecycle run (docker-compose) + runs-on: ubuntu-latest + needs: backend + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Generate throwaway canary keys + run: | + node -e ' + const { Keypair } = require("@stellar/stellar-sdk"); + const u = Keypair.random(), s = Keypair.random(); + console.log(`CANARY_USER_SECRET=${u.secret()}`); + console.log(`CANARY_SOLVER_SECRET=${s.secret()}`); + console.log(`CANARY_ADDRESSES=${u.publicKey()},${s.publicKey()}`); + ' >> "$GITHUB_ENV" + - name: Start stack + run: docker compose up -d --build --wait postgres redis && docker compose up -d app + - name: Wait for API + run: timeout 300 sh -c 'until curl -sf http://localhost:4000/health/live; do sleep 5; done' + - name: Run canary once + env: + CANARY_SETTLE_ONCHAIN: "false" + run: npm run canary -- --once + - name: Stack logs + if: failure() + run: docker compose logs app + + # ── Migration rollback verification ──────────────────────────────────────── + # For every prisma/migrations/*/down.sql, applies migration.sql then + # down.sql against a fresh Postgres and asserts the schema returns to its + # pre-migration (empty) state. See prisma/migrations/README.md. + migration-rollback: + name: Migration rollback verification + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex_rollback_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: vortex + PGPASSWORD: vortex + PGDATABASE: vortex_rollback_test + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Apply each migration's up then down script and verify schema is restored + run: | + set -euo pipefail + for dir in prisma/migrations/*/; do + name=$(basename "$dir") + up="$dir/migration.sql" + down="$dir/down.sql" + [ -f "$down" ] || continue + + echo "::group::$name" + before=$(psql -Atc "select tablename from pg_tables where schemaname='public' order by 1") + psql -v ON_ERROR_STOP=1 -f "$up" + psql -v ON_ERROR_STOP=1 -f "$down" + after=$(psql -Atc "select tablename from pg_tables where schemaname='public' order by 1") + + if [ "$before" != "$after" ]; then + echo "Schema after down.sql does not match pre-migration state for $name" + echo "before: $before" + echo "after: $after" + exit 1 + fi + echo "::endgroup::" + done + + # ── Migration lint ───────────────────────────────────────────────────────── + # Blocks unsafe DDL in migrations this change adds or modifies: non-concurrent + # index builds/drops, column type rewrites, NOT NULL without a default, + # lock-heavy constraints, LOCK TABLE. Also requires a down.sql in every such + # migration. Untouched migrations aren't linted, so older ones can't fail + # retroactively. The rules and the `-- squawk-ignore` override are in + # prisma/migrations/README.md. + migration-lint: + name: Migration lint (squawk) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + # Full history so the diff base below is always present. + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # --ignore-scripts skips Prisma's engine download and the husky hook; + # neither is needed here, and it keeps the job well under 2 minutes. + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Test the migration checker (fixtures) + run: npm run test:scripts + + - name: Lint changed migrations + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + run: | + # Pull requests: GitHub checks out a merge commit whose first parent is + # the base branch tip, so HEAD^1 is exactly "everything this PR adds". + # Pushes: diff against the commit that was on the branch before the push. + if [ "$EVENT_NAME" = "pull_request" ] || [ -z "$BEFORE_SHA" ] || [ -z "${BEFORE_SHA//0/}" ]; then + base="HEAD^1" + else + base="$BEFORE_SHA" + fi + npm run check:migrations -- --base "$base" + + # ── N-1 app against N schema (issue #497) ───────────────────────────────── + # The issue's compatibility criterion: "previous app version's e2e smoke + # suite runs against the migrated schema in CI". Concretely, this job: + # + # 1. applies the migrations of THIS revision (schema N) to a fresh Postgres; + # 2. checks out the previous revision of the app (N-1) into a second + # directory and installs its own dependencies there; + # 3. runs three things from N-1 against schema N: + # a. `prisma migrate deploy` — N-1's migration set must reconcile + # against the N database. This is the exact "misordered migration" + # failure the issue describes: if a migration was rewritten, + # renamed or re-ordered between N-1 and N, this fails here instead + # of in production; + # b. a raw query through N-1's *generated Prisma client* — the e2e + # harness (test/utils/create-test-app.ts) deliberately MOCKS + # PrismaService, so the smoke specs below boot the N-1 app but do + # not touch the database. This probe is what makes the run + # schema-sensitive; + # c. N-1's e2e smoke subset (health + OpenAPI contract), when those + # specs exist at that revision. + # + # Which revision counts as "previous"? Chosen per event so that N-1 is the + # app that is actually running/deployed, not an arbitrary ancestor: + # * push: `github.event.before` — literally the tip that was on + # the branch before this push, i.e. what staging runs. On + # main that is the deployed revision by definition. + # (`HEAD^1` would be wrong for multi-commit pushes: it + # points *inside* the pushed range, at something never + # deployed.) + # * pull_request: the merge commit's first parent, i.e. the base-branch + # tip this PR is merged onto — what the branch runs. Only + # if that is not an ancestor of origin/main (checkout fell + # back to the head commit because the merge ref was + # unavailable) do we fall back to the merge-base with + # origin/main; an older base only makes the test stricter, + # never weaker. + # * first push / unreachable object: skip gracefully with an explicit + # ::notice:: — there is no N-1 to test, and failing the build for that + # would block the very first commit of a branch. + migration-compat: + name: Migration compatibility (N-1 app vs N schema) + runs-on: ubuntu-latest + # Hard budget: the issue asks for "under ~10 minutes". The two installs + # (current + N-1 worktree) dominate; everything else is seconds. + timeout-minutes: 10 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + # Full history: resolving `before`, the merge-base and the worktree + # checkout all need commits that a shallow clone would omit. + fetch-depth: 0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies (current revision) + run: npm ci + + - name: Generate Prisma client + run: npm run db:generate + + # ── Step 1: bring the database to schema N ──────────────────────────── + - name: Apply this revision's migrations (schema N) + run: npm run db:migrate:prod + + # ── Step 2: pick the previous revision, or skip with a notice ───────── + - name: Resolve the previous revision (N-1) + id: prev + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + prev="" + case "$EVENT_NAME" in + push) + # `before` is all-zeros on the first push of a ref and can be + # missing after a force-push; both are handled by the guards. + if [ -n "${BEFORE_SHA:-}" ] && [ "${BEFORE_SHA//0/}" != "" ] \ + && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then + prev="$BEFORE_SHA" + fi + ;; + pull_request) + # Merge ref: HEAD has two parents, first one is the base tip. + if git rev-parse --verify --quiet HEAD^2 >/dev/null \ + && git cat-file -e "HEAD^1^{commit}" 2>/dev/null \ + && git merge-base --is-ancestor "HEAD^1" "origin/${BASE_REF}"; then + prev=$(git rev-parse "HEAD^1") + else + # Fallback: the shared ancestor with the integration branch. + prev=$(git merge-base HEAD "origin/${BASE_REF}" 2>/dev/null || true) + fi + ;; + esac + if [ -z "$prev" ] || ! git cat-file -e "${prev}^{commit}" 2>/dev/null; then + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::No usable previous revision for this event — skipping the N-1 compatibility check (first push of the ref, force-push, or unrelated history). Nothing to compare against, so there is nothing to fail." + exit 0 + fi + if [ "$prev" = "$(git rev-parse HEAD)" ]; then + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::Previous revision equals HEAD — skipping the N-1 compatibility check (degenerate history)." + exit 0 + fi + echo "sha=$prev" >> "$GITHUB_OUTPUT" + echo "available=true" >> "$GITHUB_OUTPUT" + echo "N-1 revision: $prev ($(git log -1 --format=%s "$prev" | cut -c1-72))" + + # ── Step 3: materialise N-1 in a second directory ───────────────────── + # A `git worktree` shares the object database with the checkout above + # (no second clone), while giving N-1 its own package.json, lockfile and + # node_modules — the current tree stays untouched for the schema-N side. + # It lives under $RUNNER_TEMP so it cannot be mistaken for workspace + # content by later steps or by the artifact uploaders. + - name: Check out N-1 into a separate worktree + if: steps.prev.outputs.available == 'true' + id: worktree + run: | + set -euo pipefail + N1_DIR="$RUNNER_TEMP/n1-app" + git worktree add --detach "$N1_DIR" "${{ steps.prev.outputs.sha }}" + echo "N1_DIR=$N1_DIR" >> "$GITHUB_ENV" + echo "Checked out N-1 at $N1_DIR" + + - name: Install N-1 dependencies + if: steps.prev.outputs.available == 'true' + working-directory: ${{ env.N1_DIR }} + run: npm ci --no-audit --no-fund + + # @prisma/client's postinstall normally generates the client, but that is + # an implicit behaviour of whatever Prisma version N-1 pinned; make it + # explicit (and skip it cleanly on revisions whose package.json has no + # such script yet). + - name: Generate N-1 Prisma client + if: steps.prev.outputs.available == 'true' + working-directory: ${{ env.N1_DIR }} + run: | + if node -e 'process.exit(require("./package.json").scripts?.["db:generate"] ? 0 : 1)'; then + npm run db:generate + else + echo "No db:generate script at this revision; relying on @prisma/client postinstall." + fi + + # ── Step 4: the actual compatibility probes ─────────────────────────── + # (a) N-1's migration set must reconcile with schema N — the ordering / + # rewriting guard. + - name: N-1 `prisma migrate deploy` against schema N + if: steps.prev.outputs.available == 'true' + working-directory: ${{ env.N1_DIR }} + run: npx prisma migrate deploy + + # (b) The schema-sensitive probe: query through N-1's generated client. + # Written as a file (not `node -e`) so quoting cannot eat the SQL, and + # placed inside the worktree so `require("@prisma/client")` resolves + # N-1's client, not the current revision's. + - name: Query schema N through the N-1 Prisma client + if: steps.prev.outputs.available == 'true' + working-directory: ${{ env.N1_DIR }} + run: | + set -euo pipefail + cat > compat-smoke.js <<'JS' + const { PrismaClient } = require("@prisma/client"); + (async () => { + const prisma = new PrismaClient(); + const tables = await prisma.$queryRawUnsafe( + "SELECT count(*)::int AS n FROM information_schema.tables WHERE table_schema = 'public'", + ); + const n = Number(tables[0].n); + if (n < 1) throw new Error("schema N shows no public tables"); + // The table every released revision since init depends on. + await prisma.$queryRawUnsafe('SELECT count(*)::int FROM "intents"'); + console.log(`N-1 client queried schema N: ${n} public tables, intents readable`); + await prisma.$disconnect(); + })().catch((err) => { + console.error("N-1 client cannot use schema N:", err); + process.exit(1); + }); + JS + node compat-smoke.js + + # (c) N-1's e2e smoke subset. Existence is checked per file because the + # two specs may not have existed at every historical revision — missing + # coverage at N-1 is a reason to notice, not to fail the build. + - name: Run N-1 e2e smoke suite against schema N + if: steps.prev.outputs.available == 'true' + working-directory: ${{ env.N1_DIR }} + run: | + set -euo pipefail + smoke=() + for f in test/health.e2e-spec.ts test/openapi-contract.e2e-spec.ts; do + if [ -f "$f" ]; then + smoke+=("$f") + else + echo "::notice::$f does not exist at the N-1 revision — skipping that spec." + fi + done + if [ "${#smoke[@]}" -eq 0 ]; then + echo "::notice::No e2e smoke specs exist at the N-1 revision — nothing to run." + exit 0 + fi + npx jest --config test/jest-e2e.json --runTestsByPath "${smoke[@]}" --ci + + - name: Provenance for the executed comparison + if: always() && steps.prev.outputs.available == 'true' + run: | + echo "::notice::N-1 compatibility checked: N=${GITHUB_SHA} schema vs N-1=${{ steps.prev.outputs.sha }} app." + + # ── Docker build verification (#131) ────────────────────────────────────── + # Ensures every push that changes application code doesn't silently break + # the Dockerfile. Image publishing to a registry can be wired up separately + # once registry credentials are in place. + docker: + name: Docker – build verification + runs-on: ubuntu-latest + needs: backend + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Build Docker image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: false + tags: vortex-backend:ci + + # ── OpenAPI drift check (issue #318) ────────────────────────────────────── + # Verifies that src/generated/openapi.json (and the api-types.ts it drives) + # still matches what the current controllers would produce. A PR that + # changes a controller DTO or route without re-running `npm run generate:client` + # will fail here before stale types ship to SDK consumers. + # + # The job runs only on pull_request events so we don't block main pushes that + # are the *result* of running the generator. The sdk-publish job below + # re-generates on tags anyway, so production is always up-to-date. + openapi-drift: + name: OpenAPI drift check + runs-on: ubuntu-latest + needs: backend + if: github.event_name == 'pull_request' + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: vortex + POSTGRES_PASSWORD: vortex + POSTGRES_DB: vortex + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U vortex" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + # ── Solver SDK (issue #446): build + publish dry-run on every PR ─────────── + solver-sdk: + name: Solver SDK – build + publish dry-run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Restore cached Prisma client + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: node_modules/.prisma + key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} + + - name: Generate Prisma client + run: npm run db:generate + + - name: Run database migrations + run: npm run db:migrate:prod + + # Build first — generate:client requires dist/ to boot the throw-away app. + - name: Build + run: npm run build + + # Re-generate the client from the current controllers into a temp directory, + # then diff against the checked-in src/generated/. Any difference means + # a controller change was not followed by `npm run generate:client`. + - name: Regenerate OpenAPI client + run: npm run generate:client + + - name: Check for OpenAPI drift + run: | + if ! git diff --exit-code src/generated/; then + echo "" + echo "::error::src/generated/ is out of date. Run \`npm run generate:client\` locally," + echo "::error::commit the updated files, and push again." + echo "" + git diff src/generated/ + exit 1 + fi + echo "✅ src/generated/ is in sync with the current controllers." + - run: npm ci + - run: npm run check:version -w @vortex/solver-sdk + - run: npm run build -w @vortex/solver-sdk + - run: npm publish -w @vortex/solver-sdk --dry-run + + sdk-publish: + name: Publish generated SDK + runs-on: ubuntu-latest + needs: [backend, docker] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm ci + + - name: Regenerate client SDK + run: npm run generate:client + + - name: Verify packaged SDK installs cleanly + run: | + tmpdir=$(mktemp -d) + cd "$tmpdir" + npm init -y + npm install --silent "$GITHUB_WORKSPACE/src/generated" + npm install --silent typescript @types/node + cat <<'TS' > index.ts + import type { paths, components } from '@vortex-protocol/backend-sdk'; + + type IntentList = paths['/api/v1/intents']['get']; + type Intent = components['schemas']['Intent']; + + const fallback: Intent | undefined = undefined; + void fallback; + void IntentList; + TS + npx tsc --noEmit --moduleResolution node --module commonjs --target es2020 index.ts + + - name: Publish SDK to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + version="${GITHUB_REF_NAME#v}" + npm --prefix src/generated version "$version" --no-git-tag-version + npm --prefix src/generated publish --access public diff --git a/jest.config.js b/jest.config.js index 5c7b8f21..00bedd55 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,6 +28,7 @@ module.exports = { collectCoverageFrom: ["**/*.(t|j)s"], moduleNameMapper: { "^@nestjs/schedule$": "/../test/__mocks__/nestjs-schedule.ts", + "^@nestjs/event-emitter$": "/../test/__mocks__/nestjs-event-emitter.ts", }, }, @@ -48,6 +49,24 @@ module.exports = { ], }, }, + + // ── Simulator tooling suite (issue #452) ─────────────────────────────── + // The replay harness lives in tools/simulator/ (outside src/); same + // broader-rootDir trick as the scripts suite. + { + displayName: "tools", + testEnvironment: "node", + rootDir: ".", + testMatch: ["/tools/**/*.spec.ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: "./tsconfig.tools.json", + }, + ], + }, + }, ], // Coverage is collected from the project-level collectCoverageFrom above. diff --git a/package-lock.json b/package-lock.json index 3816fce3..c5682917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*" ], "dependencies": { + "@aws-sdk/client-s3": "^3.654.0", "@bull-board/api": "^9.10.1", "@bull-board/express": "^9.10.1", "@msgpack/msgpack": "^3.0.0", @@ -59,14 +60,14 @@ "@commitlint/config-conventional": "^19.8.1", "@msgpack/msgpack": "^3.0.0", "@nestjs/cli": "^11.0.24", - "@nestjs/schematics": "^12.0.5", + "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.28", "@stryker-mutator/core": "^8.0.0", "@stryker-mutator/jest-runner": "^8.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.25", "@types/jest": "^30.0.0", - "@types/node": "^20.14.2", + "@types/node": "^26.6.3", "@types/parquetjs": "^0.10.6", "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", @@ -76,7 +77,7 @@ "eslint": "^8.57.0", "fast-check": "^4.10.2", "husky": "^9.1.7", - "jest": "^30.5.2", + "jest": "^30.4.2", "openapi-typescript": "^7.8.0", "prisma": "5.22.0", "supertest": "^7.2.2", @@ -280,6 +281,314 @@ "module-details-from-path": "^1.0.4" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz", + "integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1145.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1145.0.tgz", + "integrity": "sha512-MPKAQdx8qCZW1/ChDPILNehtAn/BMP4GNTnaH/xhGCWg3v50ADVq1K2WzxIrFfeANdWUyjwiY0CXDBhclsNWgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/middleware-sdk-s3": "^3.972.77", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz", + "integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -987,8 +1296,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@borewit/text-codec": { "version": "0.2.2", @@ -1544,7 +1852,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", @@ -1556,7 +1863,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1567,7 +1873,6 @@ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -2678,7 +2983,6 @@ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, - "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2695,7 +2999,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, - "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } @@ -2705,7 +3008,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2715,11 +3017,10 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -2733,7 +3034,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -2746,7 +3046,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, @@ -2762,7 +3061,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -2775,7 +3073,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2790,17 +3087,16 @@ } }, "node_modules/@jest/console": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", - "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2808,18 +3104,17 @@ } }, "node_modules/@jest/core": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", - "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/console": "30.5.2", - "@jest/pattern": "30.5.0", - "@jest/reporters": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -2827,20 +3122,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.5.1", - "jest-config": "30.5.2", - "jest-haste-map": "30.5.1", - "jest-message-util": "30.5.1", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-resolve-dependencies": "30.5.2", - "jest-runner": "30.5.2", - "jest-runtime": "30.5.2", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", - "jest-watcher": "30.5.2", - "pretty-format": "30.5.1", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -2856,159 +3151,134 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", - "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, - "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", - "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.5.2", - "@jest/types": "30.5.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.5.2" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", - "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, - "license": "MIT", "dependencies": { - "expect": "30.5.2", - "jest-snapshot": "30.5.2" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", - "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0" + "@jest/get-type": "30.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", - "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-util": "30.5.1" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", - "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, - "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", - "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/environment": "30.5.2", - "@jest/expect": "30.5.2", - "@jest/types": "30.5.1", - "jest-mock": "30.5.2" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", - "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.5.0" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/react-is-19": { - "name": "react-is", - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/reporters": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", - "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, - "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", - "@jridgewell/trace-mapping": "^0.3.31", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", - "jest-worker": "30.5.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -3025,12 +3295,54 @@ } } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/@jest/reporters/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/schemas": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", - "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, - "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -3039,13 +3351,12 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", - "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -3055,15 +3366,13 @@ } }, "node_modules/@jest/source-map": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", - "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, - "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", + "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", - "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -3071,14 +3380,13 @@ } }, "node_modules/@jest/test-result": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", - "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/console": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -3087,15 +3395,14 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", - "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/test-result": "30.5.2", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3103,23 +3410,22 @@ } }, "node_modules/@jest/transform": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", - "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.5.1", - "@jridgewell/trace-mapping": "^0.3.31", - "babel-plugin-istanbul": "^8.0.0", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -3129,14 +3435,13 @@ } }, "node_modules/@jest/types": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", - "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/pattern": "30.5.0", - "@jest/schemas": "30.5.0", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -3313,25 +3618,21 @@ ] }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nestjs/cli": { @@ -3378,96 +3679,16 @@ } } }, - "node_modules/@nestjs/cli/node_modules/@nestjs/schematics": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", - "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", - "dev": true, - "license": "MIT", + "node_modules/@nestjs/common": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", + "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", "dependencies": { - "@angular-devkit/core": "19.2.24", - "@angular-devkit/schematics": "19.2.24", - "comment-json": "5.0.0", - "jsonc-parser": "3.3.1", - "pluralize": "8.0.0" - }, - "peerDependencies": { - "prettier": "^3.0.0", - "typescript": ">=4.8.2" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "19.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", - "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.4", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "19.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", - "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.24", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@nestjs/cli/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@nestjs/common": { - "version": "11.1.28", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", - "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", - "dependencies": { - "file-type": "21.3.4", - "iterare": "1.2.1", - "load-esm": "1.0.3", - "tslib": "2.8.1", - "uid": "2.0.2" + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" }, "funding": { "type": "opencollective", @@ -3661,40 +3882,20 @@ } }, "node_modules/@nestjs/schematics": { - "version": "12.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz", - "integrity": "sha512-AuZJVAglqzjFyXVy1EIyzjyEArw6mdOF19vWTgVviJICvVAK8DT97q+6CX4v3wWF", - "dev": true, - "dependencies": { - "cron": "4.4.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0 || ^12.0.0", - "@nestjs/core": "^11.0.0 || ^12.0.0" - } - }, - "node_modules/@nestjs/schematics": { - "version": "12.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz", - "integrity": "sha512-AuZJVAglqzjFyXVy1EIyzjyEArw6mdOF19vWTgVviJICvVAK8DT97q+6CX4v3wWFhGZoDbGiDnbq6EsGub7/bA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, - "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.1.8", - "@angular-devkit/schematics": "22.1.8", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, "peerDependencies": { "prettier": "^3.0.0", - "typescript": ">=6.0.0" + "typescript": ">=4.8.2" }, "peerDependenciesMeta": { "prettier": { @@ -3703,26 +3904,25 @@ } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "22.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.8.tgz", - "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, - "license": "MIT", "dependencies": { - "ajv": "8.20.0", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.5", - "rxjs": "7.8.2", - "source-map": "0.7.6" + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" }, "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "peerDependencies": { - "chokidar": "^5.0.0" + "chokidar": "^4.0.0" }, "peerDependenciesMeta": { "chokidar": { @@ -3731,259 +3931,30 @@ } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "22.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.8.tgz", - "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", "dev": true, - "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.1.8", + "@angular-devkit/core": "19.2.24", "jsonc-parser": "3.3.1", - "magic-string": "1.0.0", - "ora": "9.4.1", - "rxjs": "7.8.2" + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" }, "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@nestjs/schematics/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@nestjs/schematics/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@nestjs/schematics/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@nestjs/schematics/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/magic-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", - "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@nestjs/schematics/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/ora": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", - "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.2", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@nestjs/schematics/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@nestjs/schematics/node_modules/string-width": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", - "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nestjs/schematics/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "tslib": "^2.1.0" } }, "node_modules/@nestjs/swagger": { @@ -5461,400 +5432,95 @@ "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", - "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", - "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.10.0", - "@opentelemetry/resources": "2.10.0", - "@opentelemetry/sdk-trace": "2.10.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.10.0.tgz", - "integrity": "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.10.0", - "@opentelemetry/core": "2.10.0", - "@opentelemetry/sdk-trace-base": "2.10.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.42.0.tgz", - "integrity": "sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", - "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.6.0", - "@parcel/watcher-darwin-arm64": "2.6.0", - "@parcel/watcher-darwin-x64": "2.6.0", - "@parcel/watcher-freebsd-x64": "2.6.0", - "@parcel/watcher-linux-arm-glibc": "2.6.0", - "@parcel/watcher-linux-arm-musl": "2.6.0", - "@parcel/watcher-linux-arm64-glibc": "2.6.0", - "@parcel/watcher-linux-arm64-musl": "2.6.0", - "@parcel/watcher-linux-x64-glibc": "2.6.0", - "@parcel/watcher-linux-x64-musl": "2.6.0", - "@parcel/watcher-win32-arm64": "2.6.0", - "@parcel/watcher-win32-x64": "2.6.0" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", - "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", - "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", - "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", - "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", - "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", - "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", - "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", - "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", - "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", - "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.10.0.tgz", + "integrity": "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.10.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/sdk-trace-base": "2.10.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", - "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=14" } }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", - "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@opentelemetry/sql-common": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.42.0.tgz", + "integrity": "sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.19.0 || >=20.6.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" } }, "node_modules/@pkgjs/parseargs": { @@ -5871,7 +5537,6 @@ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, - "license": "MIT", "engines": { "node": "^14.18.0 || >=16.0.0" }, @@ -6306,8 +5971,7 @@ "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", @@ -6327,7 +5991,6 @@ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } @@ -6337,11 +6000,91 @@ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -7027,11 +6770,10 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -7048,7 +6790,6 @@ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -7062,7 +6803,6 @@ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } @@ -7072,7 +6812,6 @@ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -7083,7 +6822,6 @@ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" } @@ -7288,11 +7026,12 @@ } }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/node-int64": { @@ -7305,6 +7044,12 @@ "@types/node": "*" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, "node_modules/@types/oracledb": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@types/oracledb/-/oracledb-6.5.2.tgz", @@ -7674,7 +7419,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -7688,7 +7432,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -7702,7 +7445,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -7716,7 +7458,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -7730,7 +7471,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -7744,7 +7484,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -7758,7 +7497,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -7772,10 +7510,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7789,10 +7523,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7806,10 +7536,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7823,10 +7549,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7840,10 +7562,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7857,10 +7575,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7874,10 +7588,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7891,10 +7601,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7908,10 +7614,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7925,10 +7627,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -7942,7 +7640,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -7956,7 +7653,6 @@ "wasm32" ], "dev": true, - "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "1.10.0", @@ -7975,7 +7671,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -7989,7 +7684,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -8003,7 +7697,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -8481,16 +8174,15 @@ } }, "node_modules/babel-jest": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", - "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/transform": "30.5.2", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^8.0.0", - "babel-preset-jest": "30.5.0", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -8503,31 +8195,26 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", - "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^7.0.1" + "test-exclude": "^6.0.0" }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", - "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, - "license": "MIT", "dependencies": { "@types/babel__core": "^7.20.5" }, @@ -8562,20 +8249,19 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", - "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, - "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.5.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -8754,6 +8440,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", @@ -8987,7 +8679,6 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -9040,7 +8731,6 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" } @@ -9210,7 +8900,6 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, - "license": "MIT", "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -9220,8 +8909,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/color": { "version": "5.0.3", @@ -9610,7 +9298,6 @@ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, - "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -9703,8 +9390,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -9714,7 +9401,6 @@ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -9844,7 +9530,6 @@ "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -10276,7 +9961,6 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, - "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -10299,32 +9983,29 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/exit-x": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", - "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.5.2", - "@jest/get-type": "30.5.0", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-util": "30.5.1" + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -10436,6 +10117,23 @@ "node": ">=12.17.0" } }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10521,24 +10219,6 @@ "bser": "2.1.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -11094,7 +10774,6 @@ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8.0.0" } @@ -11116,7 +10795,6 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -11395,8 +11073,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/http-errors": { "version": "2.0.1", @@ -11434,7 +11111,6 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } @@ -11533,7 +11209,6 @@ "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, - "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -11634,33 +11309,6 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/int53": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/int53/-/int53-0.2.4.tgz", - "integrity": "sha512-a5jlKftS7HUOhkUyYD7j2sJ/ZnvWiNlZS1ldR+g1ifQ+/UuZXIE+YTc/lK1qGj/GwAU5F8Z0e1eVq2t1J5Ob2g==", - "license": "BSD-3-Clause" - }, - "node_modules/ioredis": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", - "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "2.0.0", - "cluster-key-slot": "1.1.1", - "debug": "4.4.3", - "denque": "2.1.0", - "redis-errors": "1.2.0", - "standard-as-callback": "2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11708,7 +11356,6 @@ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -11885,7 +11532,6 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -11900,7 +11546,6 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", @@ -11915,7 +11560,6 @@ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -11947,16 +11591,15 @@ } }, "node_modules/jest": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", - "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/core": "30.5.2", - "@jest/types": "30.5.1", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.5.2" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -11974,14 +11617,13 @@ } }, "node_modules/jest-changed-files": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", - "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, - "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -11989,29 +11631,28 @@ } }, "node_modules/jest-circus": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", - "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/environment": "30.5.2", - "@jest/expect": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.5.2", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-runtime": "30.5.2", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -12020,39 +11661,21 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/jest-cli": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", - "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/core": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -12071,33 +11694,32 @@ } }, "node_modules/jest-config": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", - "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.5.0", - "@jest/pattern": "30.5.0", - "@jest/test-sequencer": "30.5.2", - "@jest/types": "30.5.1", - "babel-jest": "30.5.2", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.5.2", - "jest-docblock": "30.5.0", - "jest-environment-node": "30.5.2", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-runner": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -12121,28 +11743,69 @@ } } }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/jest-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-diff": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", - "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.5.0", - "@jest/get-type": "30.5.0", + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.5.1" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", - "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, - "license": "MIT", "dependencies": { "detect-newline": "^3.1.0" }, @@ -12151,110 +11814,105 @@ } }, "node_modules/jest-each": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", - "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "@jest/types": "30.5.1", + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", - "pretty-format": "30.5.1" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", - "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/environment": "30.5.2", - "@jest/fake-timers": "30.5.2", - "@jest/types": "30.5.1", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", - "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", - "@parcel/watcher": "^2.6.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", - "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", - "jest-worker": "30.5.1", - "picomatch": "^4.0.3" + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", - "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "pretty-format": "30.5.1" + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", - "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", + "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.5.2", - "pretty-format": "30.5.1" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", - "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -12263,126 +11921,136 @@ } }, "node_modules/jest-mock": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", - "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.5.2", - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.5.1" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, "node_modules/jest-regex-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", - "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, - "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", - "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, - "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", - "unrs-resolver": "^1.12.1" + "unrs-resolver": "^1.7.11" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", - "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, - "license": "MIT", "dependencies": { - "jest-regex-util": "30.5.0", - "jest-snapshot": "30.5.2" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", - "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/console": "30.5.2", - "@jest/environment": "30.5.2", - "@jest/source-map": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.5.0", - "jest-environment-node": "30.5.2", - "jest-haste-map": "30.5.1", - "jest-leak-detector": "30.5.1", - "jest-message-util": "30.5.1", - "jest-resolve": "30.5.1", - "jest-runtime": "30.5.2", - "jest-util": "30.5.1", - "jest-watcher": "30.5.2", - "jest-worker": "30.5.1", - "p-limit": "^3.1.0" + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", - "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.5.2", - "@jest/fake-timers": "30.5.2", - "@jest/globals": "30.5.2", - "@jest/source-map": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.2.0", + "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "es-module-lexer": "^2.1.0", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -12390,32 +12058,74 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/jest-runtime/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-snapshot": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", - "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.5.2", - "@jest/get-type": "30.5.0", - "@jest/snapshot-utils": "30.5.1", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.5.2", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.5.2", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", - "pretty-format": "30.5.1", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -12424,13 +12134,12 @@ } }, "node_modules/jest-util": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", - "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12442,18 +12151,17 @@ } }, "node_modules/jest-validate": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", - "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "@jest/types": "30.5.1", + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.5.1" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12464,7 +12172,6 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -12473,19 +12180,18 @@ } }, "node_modules/jest-watcher": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", - "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -12493,15 +12199,14 @@ } }, "node_modules/jest-worker": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", - "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -12514,7 +12219,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -12717,7 +12421,6 @@ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -12960,7 +12663,6 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -12977,6 +12679,15 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13135,19 +12846,6 @@ "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -13323,7 +13021,6 @@ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, - "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" }, @@ -13359,13 +13056,6 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT" - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -13456,7 +13146,6 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -13721,7 +13410,6 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -13901,11 +13589,15 @@ }, "node_modules/pg-cloudflare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", "license": "MIT" }, "node_modules/pg-int8": { @@ -13989,7 +13681,6 @@ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, - "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -14002,7 +13693,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -14016,7 +13706,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^4.1.0" }, @@ -14029,7 +13718,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "license": "MIT", "dependencies": { "p-try": "^2.0.0" }, @@ -14045,7 +13733,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^2.2.0" }, @@ -14119,16 +13806,15 @@ } }, "node_modules/pretty-format": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", - "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, - "license": "MIT", "dependencies": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -14258,9 +13944,9 @@ } }, "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -14271,8 +13957,7 @@ "type": "opencollective", "url": "https://opencollective.com/fast-check" } - ], - "license": "MIT" + ] }, "node_modules/q": { "version": "1.5.1", @@ -14354,6 +14039,20 @@ "node": ">= 0.10" } }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -14450,7 +14149,6 @@ "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, - "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" }, @@ -14463,7 +14161,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -14923,6 +14620,25 @@ "node": ">= 8" } }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -14936,8 +14652,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/stack-trace": { "version": "0.0.10", @@ -14982,19 +14697,6 @@ "node": ">= 0.8" } }, - "node_modules/stdin-discarder": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", - "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -15016,7 +14718,6 @@ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, - "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -15080,7 +14781,6 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -15090,7 +14790,6 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -15190,7 +14889,6 @@ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, - "license": "MIT", "dependencies": { "@pkgr/core": "^0.3.6" }, @@ -15418,119 +15116,60 @@ } }, "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, - "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "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", "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/text-extensions": { @@ -15601,6 +15240,12 @@ "node": ">=0.6.0" } }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -15843,7 +15488,6 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -16001,7 +15645,8 @@ "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true }, "node_modules/unicorn-magic": { "version": "0.3.0", @@ -16039,7 +15684,6 @@ "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -16145,7 +15789,6 @@ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, - "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -16242,6 +15885,15 @@ } } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -16797,52 +16449,288 @@ "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", "integrity": "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==", "requires": { - "@types/estree": "^1.0.8", - "astring": "^1.9.0", - "esquery": "^1.7.0", - "meriyah": "^6.1.4", - "semifies": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, + "@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.3.tgz", + "integrity": "sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q==", + "requires": { + "@apm-js-collab/code-transformer": "^0.18.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "dependencies": { + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + } + } + }, + "@apm-js-collab/tracing-hooks": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "requires": { + "@apm-js-collab/code-transformer": "^0.18.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, + "@aws-sdk/checksums": { + "version": "3.1001.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz", + "integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/client-s3": { + "version": "3.1145.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1145.0.tgz", + "integrity": "sha512-MPKAQdx8qCZW1/ChDPILNehtAn/BMP4GNTnaH/xhGCWg3v50ADVq1K2WzxIrFfeANdWUyjwiY0CXDBhclsNWgQ==", + "requires": { + "@aws-sdk/checksums": "^3.1001.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/middleware-sdk-s3": "^3.972.77", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/core": { + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", + "requires": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-env": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-http": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-ini": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-login": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "requires": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-process": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/credential-provider-web-identity": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/middleware-sdk-s3": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz", + "integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/nested-clients": { + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/signature-v4-multi-region": { + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", + "requires": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", + "requires": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" } }, - "@apm-js-collab/code-transformer-bundler-plugins": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.3.tgz", - "integrity": "sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q==", + "@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", "requires": { - "@apm-js-collab/code-transformer": "^0.18.0", - "es-module-lexer": "^2.1.0", - "magic-string": "^0.30.21", - "module-details-from-path": "^1.0.4" - }, - "dependencies": { - "magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "requires": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - } + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" } }, - "@apm-js-collab/tracing-hooks": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", - "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", "requires": { - "@apm-js-collab/code-transformer": "^0.18.0", - "debug": "^4.4.1", - "module-details-from-path": "^1.0.4" + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" } }, + "@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, "@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -18364,9 +18252,9 @@ } }, "js-yaml": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", - "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "requires": { "argparse": "^1.0.7", @@ -18415,31 +18303,31 @@ "dev": true }, "@jest/console": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", - "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "requires": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" } }, "@jest/core": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", - "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "requires": { - "@jest/console": "30.5.2", - "@jest/pattern": "30.5.0", - "@jest/reporters": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -18447,232 +18335,251 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.5.1", - "jest-config": "30.5.2", - "jest-haste-map": "30.5.1", - "jest-message-util": "30.5.1", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-resolve-dependencies": "30.5.2", - "jest-runner": "30.5.2", - "jest-runtime": "30.5.2", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", - "jest-watcher": "30.5.2", - "pretty-format": "30.5.1", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" } }, "@jest/diff-sequences": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", - "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true }, "@jest/environment": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", - "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "requires": { - "@jest/fake-timers": "30.5.2", - "@jest/types": "30.5.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.5.2" + "jest-mock": "30.4.1" } }, "@jest/expect": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", - "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "requires": { - "expect": "30.5.2", - "jest-snapshot": "30.5.2" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" } }, "@jest/expect-utils": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", - "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "requires": { - "@jest/get-type": "30.5.0" + "@jest/get-type": "30.1.0" } }, "@jest/fake-timers": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", - "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "requires": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-util": "30.5.1" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" } }, "@jest/get-type": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", - "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true }, "@jest/globals": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", - "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "requires": { - "@jest/environment": "30.5.2", - "@jest/expect": "30.5.2", - "@jest/types": "30.5.1", - "jest-mock": "30.5.2" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" } }, "@jest/pattern": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", - "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "requires": { "@types/node": "*", - "jest-regex-util": "30.5.0" + "jest-regex-util": "30.4.0" } }, - "@jest/react-is-18": { - "version": "npm:react-is@18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "@jest/react-is-19": { - "version": "npm:react-is@19.3.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", - "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", - "dev": true - }, "@jest/reporters": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", - "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "requires": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", - "@jridgewell/trace-mapping": "^0.3.31", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", - "jest-worker": "30.5.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" + }, + "dependencies": { + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + } } }, "@jest/schemas": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", - "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "requires": { "@sinclair/typebox": "^0.34.0" } }, "@jest/snapshot-utils": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", - "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "requires": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "@jest/source-map": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", - "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, "requires": { - "@jridgewell/trace-mapping": "^0.3.31", + "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", - "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" } }, "@jest/test-result": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", - "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "requires": { - "@jest/console": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "@jest/test-sequencer": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", - "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "requires": { - "@jest/test-result": "30.5.2", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" } }, "@jest/transform": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", - "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "requires": { "@babel/core": "^7.27.4", - "@jest/types": "30.5.1", - "@jridgewell/trace-mapping": "^0.3.31", - "babel-plugin-istanbul": "^8.0.0", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "@jest/types": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", - "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "requires": { - "@jest/pattern": "30.5.0", - "@jest/schemas": "30.5.0", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -18789,9 +18696,9 @@ "optional": true }, "@napi-rs/wasm-runtime": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", - "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "optional": true, "requires": { @@ -18822,59 +18729,6 @@ "typescript": "5.9.3", "webpack": "5.106.2", "webpack-node-externals": "3.0.0" - }, - "dependencies": { - "@nestjs/schematics": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", - "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", - "dev": true, - "requires": { - "@angular-devkit/core": "19.2.24", - "@angular-devkit/schematics": "19.2.24", - "comment-json": "5.0.0", - "jsonc-parser": "3.3.1", - "pluralize": "8.0.0" - }, - "dependencies": { - "@angular-devkit/core": { - "version": "19.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", - "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", - "dev": true, - "requires": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.4", - "rxjs": "7.8.1", - "source-map": "0.7.4" - } - }, - "@angular-devkit/schematics": { - "version": "19.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", - "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", - "dev": true, - "requires": { - "@angular-devkit/core": "19.2.24", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - } - } - } - }, - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "requires": { - "tslib": "^2.1.0" - } - } } }, "@nestjs/common": { @@ -18970,179 +18824,52 @@ } }, "@nestjs/schematics": { - "version": "12.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.5.tgz", - "integrity": "sha512-AuZJVAglqzjFyXVy1EIyzjyEArw6mdOF19vWTgVviJICvVAK8DT97q+6CX4v3wWFhGZoDbGiDnbq6EsGub7/bA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, "requires": { - "@angular-devkit/core": "22.1.8", - "@angular-devkit/schematics": "22.1.8", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "dependencies": { "@angular-devkit/core": { - "version": "22.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.8.tgz", - "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, "requires": { - "ajv": "8.20.0", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.5", - "rxjs": "7.8.2", - "source-map": "0.7.6" + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" } }, "@angular-devkit/schematics": { - "version": "22.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.8.tgz", - "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", "dev": true, "requires": { - "@angular-devkit/core": "22.1.8", + "@angular-devkit/core": "19.2.24", "jsonc-parser": "3.3.1", - "magic-string": "1.0.0", - "ora": "9.4.1", - "rxjs": "7.8.2" - } - }, - "ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, - "ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true - }, - "chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true - }, - "cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "requires": { - "restore-cursor": "^5.0.0" - } - }, - "cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true - }, - "is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true - }, - "is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true - }, - "log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, - "requires": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - } - }, - "magic-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", - "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "requires": { - "mimic-function": "^5.0.0" - } - }, - "ora": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", - "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", - "dev": true, - "requires": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.2", - "string-width": "^8.1.0" - } - }, - "picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true - }, - "restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "requires": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" } }, - "source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true - }, - "string-width": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", - "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", - "dev": true, - "requires": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - } - }, - "strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "requires": { - "ansi-regex": "^6.2.2" + "tslib": "^2.1.0" } } } @@ -20062,127 +19789,19 @@ "@opentelemetry/sql-common": { "version": "0.42.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.42.0.tgz", - "integrity": "sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==", - "requires": { - "@opentelemetry/core": "^2.0.0" - } - }, - "@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "requires": { - "@noble/hashes": "^1.1.5" - } - }, - "@parcel/watcher": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", - "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "dev": true, - "requires": { - "@parcel/watcher-android-arm64": "2.6.0", - "@parcel/watcher-darwin-arm64": "2.6.0", - "@parcel/watcher-darwin-x64": "2.6.0", - "@parcel/watcher-freebsd-x64": "2.6.0", - "@parcel/watcher-linux-arm-glibc": "2.6.0", - "@parcel/watcher-linux-arm-musl": "2.6.0", - "@parcel/watcher-linux-arm64-glibc": "2.6.0", - "@parcel/watcher-linux-arm64-musl": "2.6.0", - "@parcel/watcher-linux-x64-glibc": "2.6.0", - "@parcel/watcher-linux-x64-musl": "2.6.0", - "@parcel/watcher-win32-arm64": "2.6.0", - "@parcel/watcher-win32-x64": "2.6.0", - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.4" - } - }, - "@parcel/watcher-android-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", - "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", - "dev": true, - "optional": true - }, - "@parcel/watcher-darwin-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", - "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-darwin-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", - "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-freebsd-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", - "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", - "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", - "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", - "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-arm64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", - "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-x64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", - "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", - "dev": true, - "optional": true - }, - "@parcel/watcher-linux-x64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", - "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", - "dev": true, - "optional": true - }, - "@parcel/watcher-win32-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", - "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", - "dev": true, - "optional": true + "integrity": "sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==", + "requires": { + "@opentelemetry/core": "^2.0.0" + } }, - "@parcel/watcher-win32-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", - "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "dev": true, - "optional": true + "requires": { + "@noble/hashes": "^1.1.5" + } }, "@pkgjs/parseargs": { "version": "0.11.0", @@ -20511,6 +20130,63 @@ "@sinonjs/commons": "^3.0.1" } }, + "@smithy/core": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.1.tgz", + "integrity": "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==", + "requires": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "requires": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + } + }, + "@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "requires": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + } + }, + "@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "requires": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + } + }, + "@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "requires": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + } + }, + "@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "requires": { + "tslib": "^2.6.2" + } + }, "@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -21013,9 +20689,9 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" }, "@tybys/wasm-util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "optional": true, "requires": { @@ -21256,20 +20932,18 @@ } }, "@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "requires": { - "undici-types": "~6.21.0" - } - }, - "@types/node-int64": { - "version": "0.4.32", - "resolved": "https://registry.npmjs.org/@types/node-int64/-/node-int64-0.4.32.tgz", - "integrity": "sha512-xf/JsSlnXQ+mzvc0IpXemcrO4BrCfpgNpMco+GLcXkFk01k/gW9lGJu+Vof0ZSvHK6DsHJDPSbjFPs36QkWXqw==", - "dev": true, + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "requires": { - "@types/node": "*" + "undici-types": "~8.9.0" + }, + "dependencies": { + "undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==" + } } }, "@types/node-int64": { @@ -22079,37 +21753,37 @@ } }, "babel-jest": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", - "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "requires": { - "@jest/transform": "30.5.2", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^8.0.0", - "babel-preset-jest": "30.5.0", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" } }, "babel-plugin-istanbul": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", - "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^7.0.1" + "test-exclude": "^6.0.0" } }, "babel-plugin-jest-hoist": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", - "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "requires": { "@types/babel__core": "^7.20.5" @@ -22139,12 +21813,12 @@ } }, "babel-preset-jest": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", - "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "requires": { - "babel-plugin-jest-hoist": "30.5.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" } }, @@ -22256,6 +21930,11 @@ } } }, + "bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, "brace-expansion": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", @@ -22873,7 +22552,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true + "optional": true }, "detect-newline": { "version": "3.1.0", @@ -23311,17 +22990,17 @@ "dev": true }, "expect": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", - "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "requires": { - "@jest/expect-utils": "30.5.2", - "@jest/get-type": "30.5.0", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-util": "30.5.1" + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" } }, "express": { @@ -23399,6 +23078,14 @@ "dev": true, "requires": { "pure-rand": "^8.0.0" + }, + "dependencies": { + "pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true + } } }, "fast-deep-equal": { @@ -23472,13 +23159,6 @@ "bser": "2.1.1" } }, - "fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "requires": {} - }, "fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -24379,330 +24059,391 @@ } }, "jest": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", - "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "requires": { - "@jest/core": "30.5.2", - "@jest/types": "30.5.1", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.5.2" + "jest-cli": "30.4.2" } }, "jest-changed-files": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", - "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "requires": { "execa": "^5.1.1", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0" } }, "jest-circus": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", - "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "requires": { - "@jest/environment": "30.5.2", - "@jest/expect": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.5.2", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-runtime": "30.5.2", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" - }, - "dependencies": { - "pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true - } } }, "jest-cli": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", - "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "requires": { - "@jest/core": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" } }, "jest-config": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", - "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "requires": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.5.0", - "@jest/pattern": "30.5.0", - "@jest/test-sequencer": "30.5.2", - "@jest/types": "30.5.1", - "babel-jest": "30.5.2", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.5.2", - "jest-docblock": "30.5.0", - "jest-environment-node": "30.5.2", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-runner": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + } } }, "jest-diff": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", - "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "requires": { - "@jest/diff-sequences": "30.5.0", - "@jest/get-type": "30.5.0", + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.5.1" + "pretty-format": "30.4.1" } }, "jest-docblock": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", - "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "requires": { "detect-newline": "^3.1.0" } }, "jest-each": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", - "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "requires": { - "@jest/get-type": "30.5.0", - "@jest/types": "30.5.1", + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", - "pretty-format": "30.5.1" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" } }, "jest-environment-node": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", - "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "requires": { - "@jest/environment": "30.5.2", - "@jest/fake-timers": "30.5.2", - "@jest/types": "30.5.1", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" } }, "jest-haste-map": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", - "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "requires": { - "@jest/types": "30.5.1", - "@parcel/watcher": "^2.6.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", - "fdir": "^6.5.0", + "fsevents": "^2.3.3", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", - "jest-worker": "30.5.1", - "picomatch": "^4.0.3" + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" } }, "jest-leak-detector": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", - "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "requires": { - "@jest/get-type": "30.5.0", - "pretty-format": "30.5.1" + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" } }, "jest-matcher-utils": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", - "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "requires": { - "@jest/get-type": "30.5.0", + "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.5.2", - "pretty-format": "30.5.1" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" } }, "jest-message-util": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", - "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "requires": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.5.1", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "jest-mock": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", - "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "requires": { - "@jest/expect-utils": "30.5.2", - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.5.1" + "jest-util": "30.4.1" } }, + "jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "requires": {} + }, "jest-regex-util": { - "version": "30.5.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", - "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true }, "jest-resolve": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", - "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "requires": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", - "unrs-resolver": "^1.12.1" + "unrs-resolver": "^1.7.11" } }, "jest-resolve-dependencies": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", - "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "requires": { - "jest-regex-util": "30.5.0", - "jest-snapshot": "30.5.2" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" } }, "jest-runner": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", - "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "requires": { - "@jest/console": "30.5.2", - "@jest/environment": "30.5.2", - "@jest/source-map": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.5.0", - "jest-environment-node": "30.5.2", - "jest-haste-map": "30.5.1", - "jest-leak-detector": "30.5.1", - "jest-message-util": "30.5.1", - "jest-resolve": "30.5.1", - "jest-runtime": "30.5.2", - "jest-util": "30.5.1", - "jest-watcher": "30.5.2", - "jest-worker": "30.5.1", - "p-limit": "^3.1.0" + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" } }, "jest-runtime": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", - "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", - "dev": true, - "requires": { - "@jest/environment": "30.5.2", - "@jest/fake-timers": "30.5.2", - "@jest/globals": "30.5.2", - "@jest/source-map": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "requires": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.2.0", + "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "es-module-lexer": "^2.1.0", - "glob": "^13.0.6", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.5.1", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" + }, + "dependencies": { + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + } } }, "jest-snapshot": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", - "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "requires": { "@babel/core": "^7.27.4", @@ -24710,31 +24451,31 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.5.2", - "@jest/get-type": "30.5.0", - "@jest/snapshot-utils": "30.5.1", - "@jest/transform": "30.5.2", - "@jest/types": "30.5.1", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.5.2", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.5.2", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-util": "30.5.1", - "pretty-format": "30.5.1", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "jest-util": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", - "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "requires": { - "@jest/types": "30.5.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -24743,17 +24484,17 @@ } }, "jest-validate": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", - "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "requires": { - "@jest/get-type": "30.5.0", - "@jest/types": "30.5.1", + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.5.1" + "pretty-format": "30.4.1" }, "dependencies": { "camelcase": { @@ -24765,30 +24506,30 @@ } }, "jest-watcher": { - "version": "30.5.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", - "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "requires": { - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "string-length": "^4.0.2" } }, "jest-worker": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", - "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "requires": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.5.1", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -25139,6 +24880,15 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "requires": { + "tmpl": "1.0.5" + } + }, "math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -25235,12 +24985,6 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, - "mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true - }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -25394,12 +25138,6 @@ "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" }, - "node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true - }, "node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -25740,12 +25478,14 @@ }, "pg-cloudflare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", "optional": true }, "pg-connection-string": { "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==" + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==" }, "pg-int8": { "version": "1.0.1", @@ -25890,15 +25630,15 @@ "dev": true }, "pretty-format": { - "version": "30.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", - "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "requires": { - "@jest/react-is-18": "npm:react-is@^18.3.1", - "@jest/react-is-19": "npm:react-is@^19.2.5", - "@jest/schemas": "30.5.0", - "ansi-styles": "^5.2.0" + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "dependencies": { "ansi-styles": { @@ -25982,9 +25722,9 @@ "dev": true }, "pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true }, "q": { @@ -26031,6 +25771,18 @@ "unpipe": "~1.0.0" } }, + "react-is-18": { + "version": "npm:react-is@18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "react-is-19": { + "version": "npm:react-is@19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true + }, "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -26422,6 +26174,24 @@ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true }, + "source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -26465,12 +26235,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" }, - "stdin-discarder": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", - "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", - "dev": true - }, "streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -26727,81 +26491,47 @@ } }, "test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, "requires": { "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, "dependencies": { - "balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true - }, - "glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "dependencies": { - "minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.2" - } - } + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, "requires": { - "brace-expansion": "^5.0.8" - }, - "dependencies": { - "brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "requires": { - "balanced-match": "^4.0.2" - } - } + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" } }, - "path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "brace-expansion": "^1.1.7" } } } @@ -26854,6 +26584,12 @@ "os-tmpdir": "~1.0.2" } }, + "tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, "to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -27105,7 +26841,8 @@ "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true }, "unicorn-magic": { "version": "0.3.0", @@ -27250,6 +26987,15 @@ } } }, + "walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "requires": { + "makeerror": "1.0.12" + } + }, "watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", diff --git a/package.json b/package.json index 86826ea8..b899dae4 100644 --- a/package.json +++ b/package.json @@ -1,87 +1,6 @@ { "name": "vortex-backend", "version": "0.1.0", - "description": "Vortex cross-chain swap relay backend", - "author": "Vortex Protocol", - "private": true, - "license": "MIT", - "scripts": { - "build": "nest build", - "start": "node dist/main", - "start:dev": "nest start --watch", - "dev": "nest start --watch", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "typecheck": "tsc --noEmit", - "test": "jest --config jest.config.js --run", - "test:e2e": "jest --config jest.e2e.config.js --run", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev", - "db:migrate:deploy": "prisma migrate deploy", - "db:studio": "prisma studio" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.654.0", - "@bull-board/api": "^5.22.0", - "@bull-board/express": "^5.22.0", - "@msgpack/msgpack": "^3.0.0", - "@nestjs/common": "^10.3.0", - "@nestjs/config": "^3.2.0", - "@nestjs/core": "^10.3.0", - "@nestjs/platform-express": "^10.3.0", - "@nestjs/platform-ws": "^10.3.0", - "@nestjs/schedule": "^4.0.0", - "@nestjs/swagger": "^7.3.0", - "@nestjs/throttler": "^5.1.1", - "@openfeature/server-sdk": "^1.7.5", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/auto-instrumentations-node": "^0.50.0", - "@opentelemetry/core": "^1.26.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.53.0", - "@opentelemetry/sdk-node": "^0.53.0", - "@opentelemetry/sdk-trace-base": "^1.26.0", - "@opentelemetry/sdk-trace-node": "^1.26.0", - "@prisma/client": "^5.18.0", - "@sentry/node": "^8.33.0", - "@stellar/stellar-sdk": "^12.3.0", - "bullmq": "^5.12.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "express": "^4.21.0", - "helmet": "^7.1.0", - "ioredis": "^5.4.1", - "joi": "^17.13.3", - "parquetjs": "npm:@dsnp/parquetjs@^1.5.0", - "pg": "^8.13.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "uuid": "^10.0.0", - "viem": "^2.21.0", - "winston": "^3.14.2", - "ws": "^8.18.0", - "yaml": "^2.5.1", - "zod": "^3.23.8" - }, - "devDependencies": { - "@nestjs/schematics": "^10.1.4", - "@nestjs/testing": "^10.3.0", - "@types/express": "^4.17.21", - "@types/jest": "^29.5.13", - "@types/node": "^20.16.0", - "@types/pg": "^8.11.10", - "@types/supertest": "^6.0.2", - "@types/ws": "^8.5.12", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.1", - "jest": "^29.7.0", - "prisma": "^5.18.0", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" "private": true, "workspaces": [ "packages/*" @@ -105,6 +24,7 @@ "check:env-drift": "tsx scripts/check-env-drift.ts", "seed": "tsx scripts/seed.ts", "solver:demo": "tsx scripts/solver-bot.ts", + "simulate": "tsx tools/simulator/cli.ts", "canary": "tsx tools/canary/canary.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", @@ -116,6 +36,7 @@ "prepare": "husky" }, "dependencies": { + "@aws-sdk/client-s3": "^3.654.0", "@bull-board/api": "^9.10.1", "@bull-board/express": "^9.10.1", "@nestjs/common": "^11.1.28", @@ -124,7 +45,6 @@ "@nestjs/event-emitter": "^12.0.1", "@nestjs/platform-express": "^11.1.28", "@nestjs/platform-ws": "^11.1.28", - "@nestjs/schedule": "^6.1.3", "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", @@ -145,7 +65,7 @@ "dotenv": "^16.4.5", "helmet": "^7.1.0", "ioredis": "^6.0.0", - "joi": "^18.2.9", + "joi": "^18.2.3", "parquetjs": "^0.11.2", "pg": "8.23.0", "prom-client": "^15.1.3", @@ -156,35 +76,32 @@ "viem": "^2.44.4", "winston": "^3.13.0", "ws": "^8.18.0", - "zod": "^4.6.5", - "@msgpack/msgpack": "^3.0.0", - "joi": "^18.2.9" + "zod": "^3.23.8", + "@msgpack/msgpack": "^3.0.0" }, "devDependencies": { - "@commitlint/cli": "^21.2.3", - "@commitlint/config-conventional": "^21.2.3", + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1", "@nestjs/cli": "^11.0.24", "@nestjs/schematics": "^11.1.0", - "@msgpack/msgpack": "^3.0.0", - "joi": "^18.2.9" - "@nestjs/testing": "^12.1.0", + "@nestjs/testing": "^11.1.28", "@stryker-mutator/core": "^8.0.0", "@stryker-mutator/jest-runner": "^8.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.25", "@types/jest": "^30.0.0", - "@types/node": "^20.14.2", + "@types/node": "^26.6.3", "@types/parquetjs": "^0.10.6", "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@types/ws": "^8.5.12", "@msgpack/msgpack": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^8.70.1", + "@typescript-eslint/eslint-plugin": "^7.13.0", "@typescript-eslint/parser": "^7.13.0", "eslint": "^8.57.0", "fast-check": "^4.10.2", "husky": "^9.1.7", - "jest": "^30.5.2", + "jest": "^30.4.2", "openapi-typescript": "^7.8.0", "prisma": "5.22.0", "supertest": "^7.2.2", diff --git a/packages/solver-sdk/src/generated/api-types.ts b/packages/solver-sdk/src/generated/api-types.ts index 124cdfca..43c18a41 100644 --- a/packages/solver-sdk/src/generated/api-types.ts +++ b/packages/solver-sdk/src/generated/api-types.ts @@ -12,14 +12,14 @@ // prettier-ignore export interface paths { - "/health": { + "/metrics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["HealthController_check"]; + get: operations["MetricsController_index"]; put?: never; post?: never; delete?: never; @@ -28,14 +28,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/chain/health": { + "/ops/killswitch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getHealth"]; + /** Current kill-switch state and propagation health. */ + get: operations["KillSwitchController_status"]; put?: never; post?: never; delete?: never; @@ -44,62 +45,64 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/chain/ledger": { + "/ops/killswitch/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getLatestLedger"]; + get?: never; put?: never; - post?: never; + /** Pause a scope. Effective on every replica within the propagation budget. */ + post: operations["KillSwitchController_pause"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/chain/network": { + "/ops/killswitch/resume/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getNetwork"]; + get?: never; put?: never; - post?: never; + /** Approve a resume. Takes effect once two distinct operators have approved. */ + post: operations["KillSwitchController_approveResume"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/chain/account/{publicKey}": { + "/intents": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getAccount"]; + get: operations["IntentsController_list"]; put?: never; - post?: never; + post: operations["IntentsController_create"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/tokens": { + "/intents/open": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["TokensController_getTokens"]; + get: operations["IntentsController_listOpen"]; put?: never; post?: never; delete?: never; @@ -108,14 +111,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/tokens/stellar": { + "/intents/user/{address}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["TokensController_getStellarTokens"]; + get: operations["IntentsController_listByUser"]; put?: never; post?: never; delete?: never; @@ -124,30 +127,34 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents": { + "/intents/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_list"]; + get: operations["IntentsController_getOne"]; put?: never; - post: operations["IntentsController_create"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/open": { + "/intents/{id}/audit": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_listOpen"]; + /** + * Get audit trail for an intent + * @description Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why. + */ + get: operations["IntentsController_getAudit"]; put?: never; post?: never; delete?: never; @@ -156,14 +163,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents/user/{address}": { + "/intents/{id}/quote": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_listByUser"]; + get: operations["IntentsController_getPersistedQuote"]; put?: never; post?: never; delete?: never; @@ -172,75 +179,79 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents/{id}": { + "/intents/batch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getOne"]; + get?: never; put?: never; - post?: never; + /** + * Batch-fetch current intent records by ID + * @description Returns the current record for each supplied intent ID. IDs with no matching record are omitted (not individually 404'd). Capped at 100 IDs. + */ + post: operations["IntentsController_batchLookup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/audit": { + "/intents/batch-create": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get audit trail for an intent - * @description Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why. + * Create multiple intents atomically + * @description Creates up to 50 intents in a single atomic (all-or-nothing) request. If any item fails validation or exceeds open intent limits, zero intents are created and per-item errors are reported. */ - get: operations["IntentsController_getAudit"]; - put?: never; - post?: never; + post: operations["IntentsController_batchCreate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/quote": { + "/intents/{id}/accept": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getPersistedQuote"]; + get?: never; put?: never; - post?: never; + post: operations["IntentsController_accept"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/auction": { + "/intents/{id}/fill": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getAuctionPrice"]; + get?: never; put?: never; - post?: never; + post: operations["IntentsController_fill"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/accept": { + "/intents/{id}/cancel": { parameters: { query?: never; header?: never; @@ -249,14 +260,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_accept"]; + post: operations["IntentsController_cancel"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/fill": { + "/intents/{id}/amend": { parameters: { query?: never; header?: never; @@ -265,14 +276,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_fill"]; + post: operations["IntentsController_amend"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/cancel": { + "/intents/quote": { parameters: { query?: never; header?: never; @@ -281,14 +292,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_cancel"]; + post: operations["IntentsController_quote"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/quote": { + "/intents/{id}/requote": { parameters: { query?: never; header?: never; @@ -297,21 +308,42 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_quote"]; + /** Re-quote an existing open intent using its stored fields */ + post: operations["IntentsController_requote"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/solvers": { + "/docs/ws": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SolversController_getLeaderboard"]; + /** + * AsyncAPI specification for the Vortex WebSocket feed + * @description Returns the AsyncAPI 2.6 YAML document describing the vortex.v1 subprotocol. Use this with AsyncAPI Studio or SDK generators. + */ + get: operations["WsDocsController_getSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getLegacyLeaderboard"]; put?: never; post: operations["SolversController_register"]; delete?: never; @@ -320,7 +352,43 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}": { + "/solvers/leaderboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Windowed solver leaderboard + * @description Returns the ranked solver list for a specific window. This endpoint is intended for recent-performance visibility and does not alter the legacy all-time leaderboard. + */ + get: operations["SolversController_getLeaderboard"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/eligible-intents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getEligibleIntents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}": { parameters: { query?: never; header?: never; @@ -336,7 +404,7 @@ export interface paths { patch: operations["SolversController_updateSolver"]; trace?: never; }; - "/api/v1/solvers/{address}/stats": { + "/solvers/{address}/stats": { parameters: { query?: never; header?: never; @@ -352,7 +420,55 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/deregister": { + "/solvers/{address}/slashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getSlashHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/slashes/{slashId}/dispute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SolversController_submitDispute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/slashes/{slashId}/dispute/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SolversController_resolveDispute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/deregister": { parameters: { query?: never; header?: never; @@ -368,7 +484,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/deactivate": { + "/solvers/{address}/deactivate": { parameters: { query?: never; header?: never; @@ -384,7 +500,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/reactivate": { + "/solvers/{address}/reactivate": { parameters: { query?: never; header?: never; @@ -400,14 +516,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/stats": { + "/api/v1/admin/griefing": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["StatsController_getStats"]; + /** List solvers currently under anti-griefing enforcement */ + get: operations["SolverGriefingController_listEnforced"]; put?: never; post?: never; delete?: never; @@ -416,99 +533,658 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/stats/ws": { + "/api/v1/admin/griefing/{address}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["StatsController_getWsStats"]; + /** Get anti-griefing record for a solver */ + get: operations["SolverGriefingController_getSolverRecord"]; put?: never; post?: never; - delete?: never; + /** Reset a solver's anti-griefing state to ok */ + delete: operations["SolverGriefingController_resetSolver"]; options?: never; head?: never; patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { - StellarTokenDto: { - /** @description Stellar contract ID of the token */ - contract: string; - /** @example XLM */ - symbol: string; - /** @example Stellar Lumens */ - name: string; - /** @example 7 */ - decimals: number; - /** @example 0.1182 */ - priceUSD: number; + "/api/v1/admin/griefing/{address}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - StellarTokensResponseDto: { - tokens: components["schemas"]["StellarTokenDto"][]; + /** Get anti-griefing audit log for a solver */ + get: operations["SolverGriefingController_getSolverAudit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/griefing/{address}/exclude/{intentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - CreateIntentDto: { - /** @description Stellar or EVM address of the user creating the intent */ - user: string; - /** - * @description Source chain the funds are coming from - * @enum {string} - */ - srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; - /** @description Source token contract/address on srcChain */ - srcTokenAddress: string; - /** @description Source token symbol, e.g. USDC */ - srcTokenSymbol: string; - /** @description Source token decimals */ - srcTokenDecimals: number; - /** @description Source amount as a non-negative integer string (base units) */ - srcAmount: string; - /** @description Destination Stellar token contract */ - dstTokenContract: string; - /** @description Destination token symbol, e.g. USDC */ - dstTokenSymbol: string; - /** @description Destination token decimals */ - dstTokenDecimals: number; - /** @description Minimum acceptable destination amount as an integer string */ - minDstAmount: string; - /** @description Optional Dutch-auction allocation terms */ - auction?: components["schemas"]["DutchAuctionDto"]; - /** @description Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h */ - deadline?: number; - /** @description EIP-712 signature for EVM-originated intent creation */ - signature?: string; - /** @description Unique nonce included in the EIP-712 signature */ - nonce?: string; - /** @description Unix timestamp when the EIP-712 signature expires */ - expiresAt?: number; - /** @description Idempotency key for deduplicating duplicate requests */ - idempotencyKey?: string; + get?: never; + put?: never; + /** Exclude an incident intent from griefing ratio */ + post: operations["SolverGriefingController_excludeIncident"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - DutchAuctionDto: { - /** @description Starting destination amount in base units */ - startDstAmount: string; - /** @description Unix timestamp when the price decay starts */ - decayStart: number; - /** @description Unix timestamp when the price reaches minDstAmount */ - decayEnd: number; - /** @description Solver address exclusively eligible until exclusivityEnd */ - exclusiveSolver?: string; - /** @description Unix timestamp when solver exclusivity ends */ - exclusivityEnd?: number; + get: operations["TokensController_getTokens"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tokens/stellar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - AcceptIntentDto: { - /** @description Solver address accepting the intent */ - solver: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64-encoded Ed25519 signature of the signed intent action */ - signature: string; + get: operations["TokensController_getStellarTokens"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/ledger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getLatestLedger"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getNetwork"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/account/{publicKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getAccount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/shadow-report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Daily shadow-mode divergence summary + * @description Returns (expected, simulated) comparison counts for on-chain simulations run in parallel with the off-chain intent path, broken down by transition and by divergence reason, plus a per-UTC-day series and shadow queue health. Headline totals are lifetime-to-date; `days` bounds only the per-day breakdown. + */ + get: operations["ShadowController_shadowReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/params": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Current and pending governance-controlled protocol parameters + * @description Returns the actively-enforced protocol parameters, any pending governance change with its timelock execution ledger and ETA, and recent parameter history. Parameters are sourced from the on-chain governance contract when PARAMS_CONTRACT_ID is configured; code/env defaults are used otherwise. + */ + get: operations["ParamsController_getParams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/governance/guardian/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Active guardian actions (with tx references) and effective pause state */ + get: operations["GuardianController_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/governance/guardian/actions/{id}/override": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Superadmin override of an active guardian action (audited) */ + post: operations["GuardianController_override"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a solver's credentials + * @description Lists credential metadata (never plaintext) for the authenticated solver. + */ + get: operations["SolverCredentialController_list"]; + put?: never; + /** + * Create a scoped solver credential + * @description Mints a new scoped credential for the authenticated solver. The plaintext secret is returned ONCE. Requires a valid SEP-10 JWT for the solver. + */ + post: operations["SolverCredentialController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate a solver credential + * @description Revokes the credential and issues a replacement with the same scopes. The new plaintext is returned ONCE. + */ + post: operations["SolverCredentialController_rotate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials/{id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke a solver credential + * @description Instantly revokes the credential and propagates to all replicas. + */ + post: operations["SolverCredentialController_revoke"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/jobs/queues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Depth and dead-letter counts per queue */ + get: operations["JobsController_queues"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/jobs/queues/{queue}/dead-letters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Most recent dead-lettered jobs for a queue */ + get: operations["JobsController_deadLetters"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Managed flags with env default, override pin and stored rules */ + get: operations["FlagsController_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set a flag's default and targeting rules + * @description Returns 202-style { status: 'pending' } when a second approval is required. + */ + put: operations["FlagsController_update"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/change-requests/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve a pending change (must be a different admin than the proposer) */ + post: operations["FlagsController_approve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/{key}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit trail for a flag, newest first */ + get: operations["FlagsController_auditLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_live"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_ready"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/startup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_startup"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats/treasury": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getTreasuryStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats/ws": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getWsStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get treasury reconciliation summary + * @description Returns daily reconciliation summary showing expected vs actual balances per asset + */ + get: operations["TreasuryController_getReconciliation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get detailed reconciliation for an asset + * @description Returns detailed reconciliation data including transaction breakdown + */ + get: operations["TreasuryController_getAssetReconciliation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation/trigger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Manually trigger treasury reconciliation + * @description Admin endpoint to trigger reconciliation on-demand + */ + post: operations["TreasuryController_triggerReconciliation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + PauseKillSwitchDto: { + /** + * @description Breadth of the pause. + * @enum {string} + */ + scope: "global" | "chain" | "token" | "operation"; + /** @description Required for chain/token/operation scope. */ + chain?: string; + /** @description Required for token scope; wildcard for operation scope. */ + token?: string; + /** + * @description Required for operation scope. + * @enum {string} + */ + operation?: "create" | "accept" | "fill" | "slash" | "onchain"; + /** @enum {string} */ + reasonCode: "INCIDENT" | "TOKEN_DEPEGGED" | "SOLVER_INCIDENT" | "CHAIN_DEGRADED" | "RPC_DEGRADED" | "REGULATORY" | "MAINTENANCE"; + /** @description Operator explanation, shown to clients and in the audit log. */ + reason: string; + }; + ApproveResumeDto: { + /** @description Why the resume is safe. */ + note?: string; + /** + * @description Distinct approvals needed. + * @default 2 + */ + approvalsRequired: number; + }; + CreateIntentDto: { + /** @description Stellar address of the user creating the intent */ + user: string; + /** + * @description Source chain the funds are coming from + * @enum {string} + */ + srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Source token contract/address on srcChain */ + srcTokenAddress: string; + /** @description Source token symbol, e.g. USDC */ + srcTokenSymbol: string; + /** @description Source token decimals */ + srcTokenDecimals: number; + /** @description Source amount as a non-negative integer string (base units) */ + srcAmount: string; + /** @description Destination Stellar token contract */ + dstTokenContract: string; + /** @description Destination token symbol, e.g. USDC */ + dstTokenSymbol: string; + /** @description Destination token decimals */ + dstTokenDecimals: number; + /** @description Minimum acceptable destination amount as an integer string */ + minDstAmount: string; + /** @description Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h */ + deadline?: number; + /** @description Idempotency key for deduplicating duplicate requests */ + idempotencyKey?: string; + }; + BatchLookupDto: { + /** @description Intent IDs to look up (max 100). IDs with no matching record are omitted from the response, not individually 404'd. */ + intentIds: string[]; + }; + BatchCreateIntentsDto: { + /** @description List of intents to create atomically (1..50) */ + intents: components["schemas"]["CreateIntentDto"][]; + }; + BatchCreateItemErrorDto: { + /** @description Zero-based index of the failed intent item in the input array */ + index: number; + /** @description Field name associated with the error, if applicable */ + field?: string; + /** @description Human-readable error description */ + message: string; + }; + BatchCreateIntentsResponseDto: { + /** @description Array of created Intent objects when successful */ + created: string[]; + /** @description List of per-item validation errors if any failed */ + errors: components["schemas"]["BatchCreateItemErrorDto"][]; + }; + AcceptIntentDto: { + /** @description Solver address accepting the intent */ + solver: string; + /** @description Base64-encoded Ed25519 signature of the message "accept::" produced by the solver's private key */ + signature: string; }; FillIntentDto: { /** @description Solver address filling the intent (must match the accepting solver) */ @@ -517,155 +1193,1117 @@ export interface components { fillAmount: string; /** @description Stellar fill transaction hash */ txHash?: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64-encoded Ed25519 signature of the signed intent action */ + /** @description Base64-encoded Ed25519 signature of the message "fill::" produced by the solver's private key */ + signature: string; + }; + CancelIntentDto: { + /** @description Stellar address of the intent's original creator (must match) */ + user: string; + /** @description Base64-encoded Ed25519 signature of the message "cancel:" produced by the private key of `user` */ + signature: string; + }; + AmendIntentDto: { + /** @description Stellar address of the intent's original creator (must match) */ + user: string; + /** @description Replacement minimum destination amount in base units */ + minDstAmount: string; + /** @description Replacement Unix timestamp deadline */ + deadline: number; + /** @description Base64 Ed25519 signature of "amend::::" */ + signature: string; + }; + QuoteRequestDto: { + /** + * @description Source chain the funds are coming from + * @enum {string} + */ + srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Source token symbol, e.g. USDC */ + srcTokenSymbol: string; + /** @description Source amount as a non-negative integer string */ + srcAmount: string; + /** @description Destination token symbol, e.g. USDC */ + dstTokenSymbol: string; + /** @description Intent ID to persist the quote to */ + intentId?: string; + /** @description Source token contract address / ID (used for precise token resolution) */ + srcTokenAddress?: string; + /** @description Destination Stellar token contract ID (used for precise token resolution) */ + dstTokenContract?: string; + }; + RouteStepDto: { + /** @enum {string} */ + type: "bridge" | "swap" | "transfer"; + /** @description Protocol name, e.g. 'direct-solver', 'uniswap-v3' */ + protocol: string; + fromChain: string; + toChain: string; + /** @description Source token info for this hop */ + fromToken: Record; + /** @description Destination token info for this hop */ + toToken: Record; + /** @description Estimated execution time in seconds for this step */ + estimatedTime: number; + /** @description Estimated gas cost in the source token's base unit */ + estimatedGas: string; + }; + RouteDto: { + /** @description Ordered list of steps to execute the swap */ + steps: components["schemas"]["RouteStepDto"][]; + /** @description Total estimated time for all steps in seconds */ + totalTime: number; + /** @description Total fees in USD across all steps */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; + }; + QuoteDto: { + /** @description Solver address */ + solver: string; + /** @description Solver name */ + solverName: string; + /** @description Destination amount as a string */ + dstAmount: string; + /** @description Protocol fee as a string */ + fee: string; + /** @description Estimated fill time in seconds */ + fillTime: number; + /** @description Unix timestamp when quote expires */ + expiresAt: number; + /** @description Total fees in USD (protocol fee converted at token price) */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; + /** @description Computed execution route (direct single-step or multi-hop via USDC intermediate) */ + route: components["schemas"]["RouteDto"]; + }; + QuoteResponseDto: { + /** @description Array of quotes sorted by best dstAmount first */ + quotes: components["schemas"]["QuoteDto"][]; + /** @description Best quote or null if no solvers available */ + bestQuote: components["schemas"]["QuoteDto"] | null; + /** @description Source chain */ + srcChain: string; + /** @description Source token symbol */ + srcTokenSymbol: string; + /** @description Source amount as a string */ + srcAmount: string; + /** @description Destination token symbol */ + dstTokenSymbol: string; + /** @description Estimated fill time in seconds for the best quote */ + estimatedFillTime: number; + /** @description Total fees in USD for the best quote (0 when no quote available) */ + totalFeesUSD: number; + /** @description Price impact for the best quote as a decimal fraction (0 when no quote available) */ + priceImpact: number; + /** @description True when no solver responded and the returned quote is indicative */ + indicative?: boolean; + }; + RegisterSolverDto: { + /** @description Solver's Stellar address */ + address: string; + /** @description Solver's display name */ + name: string; + /** @description Bond amount as a non-negative integer string (in USDC base units) */ + bondAmount: string; + /** @description Average fill time in seconds */ + avgFillTime: number; + /** @description Chains this solver supports */ + supportedChains: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; + /** @description Token symbols this solver supports */ + supportedTokens: unknown[][]; + /** @description Proof-of-control signature for the advertised solver address */ + proofSignature: string; + }; + UpdateSolverDto: { + /** @description New display name */ + name?: string; + /** @description Replacement list of chains this solver supports */ + supportedChains?: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; + /** @description Replacement list of supported token symbols */ + supportedTokens?: unknown[][]; + /** @description Updated average fill time in seconds */ + avgFillTime?: number; + /** @description Base64-encoded Ed25519 signature of the message "update-solver:
" produced by the solver's private key, proving control of :address */ signature: string; }; - CancelIntentDto: { - /** @description Stellar or EVM address of the intent's original creator (must match) */ - user: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64 Ed25519 or EIP-712 signature proving control of user */ - signature: string; + UpdateSolverStatusDto: { + /** @description Proof-of-control signature for the solver status update */ + signature: string; + }; + StellarTokenDto: { + /** @description Stellar contract ID of the token */ + contract: string; + /** @example XLM */ + symbol: string; + /** @example Stellar Lumens */ + name: string; + /** @example 7 */ + decimals: number; + /** @example 0.1182 */ + priceUSD: number; + }; + StellarTokensResponseDto: { + tokens: components["schemas"]["StellarTokenDto"][]; + }; + GuardianOverrideDto: Record; + CreateSolverCredentialDto: { + /** + * @description Scopes granted to the credential + * @enum {string} + */ + scopes: "solver:read" | "intents:read" | "quote:respond" | "intents:accept" | "intents:fill"; + /** @description Optional source-address allowlist. Entries are an exact IPv4 address, an IPv4 CIDR block, an exact IPv6 address, or `*` for any source. IPv6 CIDR blocks and hostnames are not supported; an entry that does not parse never matches, so a typo locks the credential out rather than opening it up. */ + ipAllowlist?: string[]; + /** @description Unix epoch seconds at which the credential expires (null = never) */ + expiresAt?: number; + }; + UpdateFlagDto: Record; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + MetricsController_index: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_pause: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PauseKillSwitchDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_approveResume: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ApproveResumeDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_list: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid limit or offset */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateIntentDto"]; + }; + }; + responses: { + /** @description Invalid request body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Open-intent cap reached — a single user may not hold more than 50 open/accepted intents simultaneously */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally) */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_listOpen: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_listByUser: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getOne: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getAudit: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit trail for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + intentId?: string; + entries?: { + /** Format: date-time */ + timestamp?: string; + toState?: string; + actor?: string; + reason?: string; + metadata?: Record | null; + }[]; + }; + }; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getPersistedQuote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Persisted quote for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found or no quote persisted */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_batchLookup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchLookupDto"]; + }; + }; + responses: { + /** @description Records for the found intent IDs, plus a count */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description intentIds missing, not an array of strings, or exceeds 100 entries */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_batchCreate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchCreateIntentsDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchCreateIntentsResponseDto"]; + }; + }; + /** @description Invalid request payload or empty batch */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Per-item validation errors or limits exceeded */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_accept: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptIntentDto"]; + }; + }; + responses: { + /** @description Solver not registered or inactive */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent has expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_fill: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FillIntentDto"]; + }; + }; + responses: { + /** @description Fill amount below minimum */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Wrong solver for this intent */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in accepted state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Fill window has expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description An emergency kill-switch is active for this intent's scope (503 + Retry-After) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_cancel: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CancelIntentDto"]; + }; + }; + responses: { + /** @description Unauthorized */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_amend: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AmendIntentDto"]; + }; + }; + responses: { + /** @description Unauthorized */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not amendable */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_quote: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuoteRequestDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuoteResponseDto"]; + }; + }; + /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_requote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuoteResponseDto"]; + }; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in the open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + WsDocsController_getSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getLegacyLeaderboard: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterSolverDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getLeaderboard: { + parameters: { + query?: { + window?: "24h" | "7d" | "30d" | "all"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getEligibleIntents: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_updateSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverDto"]; + }; }; - QuoteRequestDto: { - /** - * @description Source chain the funds are coming from - * @enum {string} - */ - srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; - /** @description Source token symbol, e.g. USDC */ - srcTokenSymbol: string; - /** @description Source amount as a non-negative integer string */ - srcAmount: string; - /** @description Destination token symbol, e.g. USDC */ - dstTokenSymbol: string; - /** @description Intent ID to persist the quote to */ - intentId?: string; - /** @description Source token contract address / ID (used for precise token resolution) */ - srcTokenAddress?: string; - /** @description Destination Stellar token contract ID (used for precise token resolution) */ - dstTokenContract?: string; + responses: { + /** @description Updated solver record */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid update body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Solver not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - RouteStepDto: { - /** @enum {string} */ - type: "bridge" | "swap" | "transfer"; - /** @description Protocol name, e.g. 'direct-solver', 'uniswap-v3' */ - protocol: string; - fromChain: string; - toChain: string; - /** @description Source token info for this hop */ - fromToken: Record; - /** @description Destination token info for this hop */ - toToken: Record; - /** @description Estimated execution time in seconds for this step */ - estimatedTime: number; - /** @description Estimated gas cost in the source token's base unit */ - estimatedGas: string; + }; + SolversController_getSolverStats: { + parameters: { + query: { + window: string; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; }; - RouteDto: { - /** @description Ordered list of steps to execute the swap */ - steps: components["schemas"]["RouteStepDto"][]; - /** @description Total estimated time for all steps in seconds */ - totalTime: number; - /** @description Total fees in USD across all steps */ - totalFeesUSD: number; - /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ - priceImpact: number; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - QuoteDto: { - /** @description Solver address */ - solver: string; - /** @description Solver name */ - solverName: string; - /** @description Destination amount as a string */ - dstAmount: string; - /** @description Protocol fee as a string */ - fee: string; - /** @description Estimated fill time in seconds */ - fillTime: number; - /** @description Unix timestamp when quote expires */ - expiresAt: number; - /** @description Total fees in USD (protocol fee converted at token price) */ - totalFeesUSD: number; - /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ - priceImpact: number; - /** @description Computed execution route (direct single-step or multi-hop via USDC intermediate) */ - route: components["schemas"]["RouteDto"]; + }; + SolversController_getSlashHistory: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; }; - QuoteResponseDto: { - /** @description Array of quotes sorted by best dstAmount first */ - quotes: components["schemas"]["QuoteDto"][]; - /** @description Best quote or null if no solvers available */ - bestQuote: components["schemas"]["QuoteDto"] | null; - /** @description Source chain */ - srcChain: string; - /** @description Source token symbol */ - srcTokenSymbol: string; - /** @description Source amount as a string */ - srcAmount: string; - /** @description Destination token symbol */ - dstTokenSymbol: string; - /** @description Estimated fill time in seconds for the best quote */ - estimatedFillTime: number; - /** @description Total fees in USD for the best quote (0 when no quote available) */ - totalFeesUSD: number; - /** @description Price impact for the best quote as a decimal fraction (0 when no quote available) */ - priceImpact: number; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - RegisterSolverDto: { - /** @description Solver's Stellar address */ - address: string; - /** @description Solver's display name */ - name: string; - /** @description Bond amount as a non-negative integer string (in USDC base units) */ - bondAmount: string; - /** @description Average fill time in seconds */ - avgFillTime: number; - /** @description Chains this solver supports */ - supportedChains: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; - /** @description Token symbols this solver supports */ - supportedTokens: unknown[][]; - /** @description Proof-of-control signature for the advertised solver address */ - proofSignature: string; + }; + SolversController_submitDispute: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + slashId: string; + }; + cookie?: never; }; - UpdateSolverDto: { - /** @description New display name */ - name?: string; - /** @description Replacement list of chains this solver supports */ - supportedChains?: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; - /** @description Replacement list of supported token symbols */ - supportedTokens?: unknown[][]; - /** @description Updated average fill time in seconds */ - avgFillTime?: number; - /** @description Base64-encoded Ed25519 signature of the message "update-solver:
" produced by the solver's private key, proving control of :address */ - signature: string; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - HealthController_check: { + SolversController_resolveDispute: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + slashId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_deregisterSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_deactivate: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_reactivate: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; responses: { - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -673,7 +2311,7 @@ export interface operations { }; }; }; - SorobanController_getHealth: { + SolverGriefingController_listEnforced: { parameters: { query?: never; header?: never; @@ -682,111 +2320,84 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Soroban RPC node health status (pass-through of the RPC `getHealth` result). */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - /** @example healthy */ - status: string; - latestLedger?: number; - oldestLedger?: number; - ledgerRetentionWindow?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getLatestLedger: { + SolverGriefingController_getSolverRecord: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Latest closed ledger as reported by the Soroban RPC node. */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - id: string; - /** @example 12345678 */ - sequence: number; - protocolVersion?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getNetwork: { + SolverGriefingController_resetSolver: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Network passphrase and protocol metadata for the configured RPC node. */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - friendbotUrl?: string | null; - /** @example Test SDF Network ; September 2015 */ - passphrase: string; - protocolVersion?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getAccount: { + SolverGriefingController_getSolverAudit: { parameters: { query?: never; header?: never; path: { - /** @description Stellar Ed25519 account public key (starts with `G`, 56 characters). */ - publicKey: string; + address: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description On-chain account record (id, sequence number, and balances). */ 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - id: string; - /** @example 987654321 */ - sequence: string; - balances?: { - balance?: string; - asset_type?: string; - }[]; - }; - }; - }; - /** @description Invalid Stellar public key format */ - 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Per-account rate limit exceeded (AccountRateLimitGuard) */ - 429: { + }; + }; + SolverGriefingController_excludeIncident: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { headers: { [name: string]: unknown; }; @@ -877,67 +2488,58 @@ export interface operations { }; }; }; - IntentsController_list: { + SorobanController_getHealth: { parameters: { - query: { - /** @description Filter by intent state */ - state?: string; - /** @description Filter by user address */ - user?: string; - /** @description Filter by source chain */ - chain?: string; - /** @description Number of results per page */ - limit: number; - /** @description Cursor for the next page of intents */ - cursor?: string; - /** @description Number of results to skip */ - offset: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Invalid limit or offset */ - 400: { + /** @description Soroban RPC node health status (pass-through of the RPC `getHealth` result). */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example healthy */ + status: string; + latestLedger?: number; + oldestLedger?: number; + ledgerRetentionWindow?: number; + }; + }; }; }; }; - IntentsController_create: { + SorobanController_getLatestLedger: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateIntentDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Invalid request body */ - 400: { + /** @description Latest closed ledger as reported by the Soroban RPC node. */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally) */ - 429: { - headers: { - [name: string]: unknown; + content: { + "application/json": { + id: string; + /** @example 12345678 */ + sequence: number; + protocolVersion?: number; + }; }; - content?: never; }; }; }; - IntentsController_listOpen: { + SorobanController_getNetwork: { parameters: { query?: never; header?: never; @@ -946,46 +2548,60 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Network passphrase and protocol metadata for the configured RPC node. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + friendbotUrl?: string | null; + /** @example Test SDF Network ; September 2015 */ + passphrase: string; + protocolVersion?: number; + }; + }; }; }; }; - IntentsController_listByUser: { + SorobanController_getAccount: { parameters: { query?: never; header?: never; path: { - address: string; + /** @description Stellar Ed25519 account public key (starts with `G`, 56 characters). */ + publicKey: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description On-chain account record (id, sequence number, and balances). */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + id: string; + /** @example 987654321 */ + sequence: string; + balances?: { + balance?: string; + asset_type?: string; + }[]; + }; + }; }; - }; - }; - IntentsController_getOne: { - parameters: { - query?: never; - header?: never; - path: { - id: string; + /** @description Invalid Stellar public key format */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Intent not found */ - 404: { + /** @description Per-account rate limit exceeded (AccountRateLimitGuard) */ + 429: { headers: { [name: string]: unknown; }; @@ -993,99 +2609,161 @@ export interface operations { }; }; }; - IntentsController_getAudit: { + ShadowController_shadowReport: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + /** @description Trailing UTC days of per-day breakdown to include (1-90, default 1). */ + days?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Audit trail for the intent */ + /** @description Shadow-mode divergence report */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - intentId?: string; - entries?: { - /** Format: date-time */ - timestamp?: string; - toState?: string; - actor?: string; - reason?: string; - metadata?: Record | null; + enabled?: boolean; + sampleRate?: number; + /** @example 2026-09-30 */ + day?: string; + /** Format: date-time */ + generatedAt?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + transitions?: { + /** @example accept */ + transition?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + }[]; + divergences?: { + /** @example fill */ + transition?: string; + /** + * @example outcome_mismatch + * @enum {string} + */ + reason?: "outcome_mismatch" | "simulation_error" | "simulation_exception" | "contract_unconfigured"; + count?: number; }[]; + daily?: { + /** @example 2026-09-30 */ + day?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + cells?: Record[]; + }[]; + queue?: { + depth?: number; + capacity?: number; + dropped?: number; + sampledOut?: number; + disabled?: number; + completed?: number; + }; }; }; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; - IntentsController_getPersistedQuote: { + ParamsController_getParams: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Persisted quote for the intent */ + /** @description Protocol parameters — current, pending, and history */ 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Intent not found or no quote persisted */ - 404: { - headers: { - [name: string]: unknown; + content: { + "application/json": { + /** @description Currently active protocol parameters */ + current: { + /** @example 3 */ + version: number; + /** + * @description Protocol fee in basis points + * @example 30 + */ + feeBps: number; + /** @description Per-chain deadline and fill-window overrides */ + chains: { + [key: string]: { + /** @example 900 */ + deadlineSeconds?: number; + /** @example 120 */ + fillWindowSeconds?: number; + }; + }; + /** + * @description Maximum on-chain exposure ratio (0–1) + * @example 0.05 + */ + maxExposureRatio: number; + /** @example 100000000 */ + slashAmount: string; + /** @example 12345678 */ + activeSinceLedger: number; + /** Format: date-time */ + adoptedAt: string; + }; + /** @description Scheduled governance change not yet activated, or null */ + pending: ({ + params?: Record; + /** @example 12349999 */ + executionLedger?: number; + /** + * Format: date-time + * @description Best-effort ETA for timelock execution + */ + estimatedEta?: string; + /** Format: date-time */ + observedAt?: string; + } | null) | null; + /** @description Previous parameter versions, newest-first (max 50) */ + history: Record[]; + }; }; - content?: never; }; }; }; - IntentsController_getAuctionPrice: { + GuardianController_status: { parameters: { query?: never; header?: never; - path: { id: string }; + path?: never; cookie?: never; }; requestBody?: never; responses: { 200: { - headers: { [name: string]: unknown }; - content: { - "application/json": { - intentId: string; - currentDstAmount: string; - acceptedDstAmount?: string; - timestamp: number; - }; + headers: { + [name: string]: unknown; }; + content?: never; }; - 404: { headers: { [name: string]: unknown }; content?: never }; }; }; - IntentsController_accept: { + GuardianController_override: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { id: string; }; @@ -1093,33 +2771,38 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AcceptIntentDto"]; + "application/json": components["schemas"]["GuardianOverrideDto"]; }; }; responses: { - /** @description Solver not registered or inactive */ - 403: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; + }; + }; + SolverCredentialController_list: { + parameters: { + query?: never; + header?: never; + path: { + address: string; }; - /** @description Intent is not in open state */ - 409: { + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Credential metadata list */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent has expired */ - 410: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1127,51 +2810,62 @@ export interface operations { }; }; }; - IntentsController_fill: { + SolverCredentialController_create: { parameters: { query?: never; header?: never; path: { - id: string; + address: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["FillIntentDto"]; + "application/json": components["schemas"]["CreateSolverCredentialDto"]; }; }; responses: { - /** @description Fill amount below minimum */ - 400: { + /** @description Credential created (plaintext shown once) */ + 201: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Wrong solver for this intent */ - 403: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; + }; + }; + SolverCredentialController_rotate: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + id: string; }; - /** @description Intent is not in accepted state */ - 409: { + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSolverCredentialDto"]; + }; + }; + responses: { + /** @description Credential rotated (new plaintext shown once) */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Fill window has expired */ - 410: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1179,37 +2873,27 @@ export interface operations { }; }; }; - IntentsController_cancel: { + SolverCredentialController_revoke: { parameters: { query?: never; header?: never; path: { + address: string; id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CancelIntentDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Unauthorized */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Intent not found */ - 404: { + /** @description Credential revoked */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent is not in open state */ - 409: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1217,29 +2901,39 @@ export interface operations { }; }; }; - IntentsController_quote: { + JobsController_queues: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["QuoteRequestDto"]; - }; - }; + requestBody?: never; responses: { 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["QuoteResponseDto"]; - }; + content?: never; }; - /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ - 429: { + }; + }; + JobsController_deadLetters: { + parameters: { + query?: never; + header: { + "x-admin-key": string; + }; + path: { + queue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; @@ -1247,10 +2941,12 @@ export interface operations { }; }; }; - SolversController_getLeaderboard: { + FlagsController_list: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path?: never; cookie?: never; }; @@ -1264,20 +2960,24 @@ export interface operations { }; }; }; - SolversController_register: { + FlagsController_update: { parameters: { query?: never; - header?: never; - path?: never; + header: { + "x-admin-key": string; + }; + path: { + key: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["RegisterSolverDto"]; + "application/json": components["schemas"]["UpdateFlagDto"]; }; }; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1285,12 +2985,14 @@ export interface operations { }; }; }; - SolversController_getSolver: { + FlagsController_approve: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { - address: string; + id: string; }; cookie?: never; }; @@ -1304,44 +3006,71 @@ export interface operations { }; }; }; - SolversController_updateSolver: { + FlagsController_auditLog: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { - address: string; + key: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateSolverDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated solver record */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Invalid update body */ - 400: { + }; + }; + HealthController_live: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Missing or invalid signature */ - 401: { + }; + }; + HealthController_ready: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Solver not found */ - 404: { + }; + }; + HealthController_startup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; @@ -1349,13 +3078,11 @@ export interface operations { }; }; }; - SolversController_getSolverStats: { + HealthController_check: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -1368,18 +3095,16 @@ export interface operations { }; }; }; - SolversController_deregisterSolver: { + StatsController_getStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1387,18 +3112,16 @@ export interface operations { }; }; }; - SolversController_deactivate: { + StatsController_getTreasuryStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1406,18 +3129,16 @@ export interface operations { }; }; }; - SolversController_reactivate: { + StatsController_getWsStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1425,37 +3146,143 @@ export interface operations { }; }; }; - StatsController_getStats: { + TreasuryController_getReconciliation: { parameters: { - query?: never; + query?: { + /** @description Date in YYYY-MM-DD format (defaults to today) */ + date?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { + /** @description Reconciliation summary */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example 2026-09-28 */ + date?: string; + assets?: { + /** @example native */ + asset?: string; + /** @example 1000000000 */ + expectedBalance?: string; + /** @example 1000500000 */ + actualBalance?: string; + /** @example 500000 */ + discrepancy?: string; + /** @example 0.05 */ + discrepancyPercentage?: number; + /** @example false */ + hasUnexplainedDiscrepancy?: boolean; + explanation?: string | null; + }[]; + /** @example 3 */ + totalDiscrepancies?: number; + /** @example 1 */ + assetsWithUnexplainedDiscrepancies?: number; + /** @example 2026-09-28T00:00:00.000Z */ + lastReconciliationAt?: string; + }; + }; }; }; }; - StatsController_getWsStats: { + TreasuryController_getAssetReconciliation: { parameters: { - query?: never; + query?: { + /** @description Date in YYYY-MM-DD format (defaults to today) */ + date?: string; + }; + header?: never; + path: { + /** @description Asset identifier (e.g., 'native', 'USDC:ISSUER', or contract address) */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Detailed reconciliation data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026-09-28 */ + snapshotDate?: string; + /** @example native */ + asset?: string; + /** @example 1000000000 */ + expectedBalance?: string; + /** @example 1000500000 */ + actualBalance?: string; + /** @example 500000 */ + discrepancy?: string; + /** @example 0.05 */ + discrepancyPercentage?: number; + /** @example 10000000 */ + toleranceThreshold?: string; + /** @example false */ + hasUnexplainedDiscrepancy?: boolean; + explanation?: string | null; + breakdown?: { + /** @example 500000000 */ + fees?: string; + /** @example 100000000 */ + slashes?: string; + /** @example 50000000 */ + refunds?: string; + }; + recentTransactions?: { + /** @enum {string} */ + type?: "fee" | "slash" | "refund"; + /** @example 1000000 */ + amount?: string; + /** @example 2026-09-28T12:00:00.000Z */ + timestamp?: string; + /** @example intent-uuid */ + reference?: string; + }[]; + }; + }; + }; + }; + }; + TreasuryController_triggerReconciliation: { + parameters: { + query?: { + /** @description Specific asset to reconcile (reconciles all if omitted) */ + asset?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { + /** @description Reconciliation completed */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example Reconciliation completed */ + message?: string; + results?: { + asset?: string; + hasUnexplainedDiscrepancy?: boolean; + discrepancy?: string; + }[]; + }; + }; }; }; }; diff --git a/prisma/migrations/20260927000000_processed_events_dead_letters/down.sql b/prisma/migrations/20260927000000_processed_events_dead_letters/down.sql new file mode 100644 index 00000000..e16d6816 --- /dev/null +++ b/prisma/migrations/20260927000000_processed_events_dead_letters/down.sql @@ -0,0 +1,11 @@ +-- Rollback for 20260927000000_processed_events_dead_letters. +-- +-- Drops the two event-ing tables this migration creates so a fresh +-- database returns to its pre-migration (empty) state. The provisional, +-- network-keyed tables this migration replaced (see the header of +-- migration.sql) are NOT recreated on rollback: they only exist on +-- databases that ran 20260926000000 before this migration, and losing +-- their (superseded) content is the documented limitation of this +-- rollback. Re-running migration.sql restores the final shape. +DROP TABLE IF EXISTS "processed_events"; +DROP TABLE IF EXISTS "dead_letter_events"; diff --git a/prisma/migrations/20260927000000_processed_events_dead_letters/migration.sql b/prisma/migrations/20260927000000_processed_events_dead_letters/migration.sql index f1601055..fa90ef20 100644 --- a/prisma/migrations/20260927000000_processed_events_dead_letters/migration.sql +++ b/prisma/migrations/20260927000000_processed_events_dead_letters/migration.sql @@ -1,6 +1,17 @@ -- Migration: processed_events + dead_letter_events -- These tables support the versioned event decoder registry (#390), -- ledger-gap backfill (#391), and chain-authoritative reconciler (#392). +-- +-- 20260926000000_tx_confirmation_channel_pool_ingestion created provisional, +-- network-keyed tables under these same names as part of its ingestion work. +-- This migration is the authoritative definition (keyed by the RPC event id +-- "-"), so on a database that has already run the earlier +-- migration the provisional tables are dropped and recreated in the final +-- shape. Without that drop, a fresh `prisma migrate deploy` fails with +-- "relation already exists". + +DROP TABLE IF EXISTS "processed_events"; +DROP TABLE IF EXISTS "dead_letter_events"; -- ─── processed_events ──────────────────────────────────────────────────────── -- Idempotency table: one row per confirmed-processed Soroban event. @@ -17,12 +28,18 @@ CREATE TABLE "processed_events" ( "processed_at" TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- squawk-ignore create-index-without-concurrently +-- justification: Prisma runs each migration inside a transaction, which forbids CREATE INDEX CONCURRENTLY; the table is fresh (or freshly recreated) in this migration, so the build takes no meaningful lock. CREATE UNIQUE INDEX "processed_events_ledger_idx_key" ON "processed_events" ("ledger", "event_index"); +-- squawk-ignore create-index-without-concurrently +-- justification: Prisma runs each migration inside a transaction, which forbids CREATE INDEX CONCURRENTLY; the table is fresh (or freshly recreated) in this migration, so the build takes no meaningful lock. CREATE INDEX "processed_events_ledger_idx" ON "processed_events" ("ledger"); +-- squawk-ignore create-index-without-concurrently +-- justification: Prisma runs each migration inside a transaction, which forbids CREATE INDEX CONCURRENTLY; the table is fresh (or freshly recreated) in this migration, so the build takes no meaningful lock. CREATE INDEX "processed_events_contract_idx" ON "processed_events" ("contract_id"); @@ -41,5 +58,7 @@ CREATE TABLE "dead_letter_events" ( "occurred_at" TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- squawk-ignore create-index-without-concurrently +-- justification: Prisma runs each migration inside a transaction, which forbids CREATE INDEX CONCURRENTLY; the table is fresh (or freshly recreated) in this migration, so the build takes no meaningful lock. CREATE INDEX "dead_letter_events_ledger_idx" ON "dead_letter_events" ("ledger"); diff --git a/prisma/migrations/20261001000000_token_fk_normalization/down.sql b/prisma/migrations/20261001000000_token_fk_normalization/down.sql new file mode 100644 index 00000000..a2ec7a75 --- /dev/null +++ b/prisma/migrations/20261001000000_token_fk_normalization/down.sql @@ -0,0 +1,17 @@ +-- Down: #410 — Normalise intent token data into foreign keys (phase 1). +-- +-- Reverses the expand/contract phase-1 migration: drops the volume indexes, +-- the deferred foreign keys, then the FK/snapshot columns. The JSON blobs +-- (src_token / dst_token) were retained throughout, so no data is lost. + +DROP INDEX IF EXISTS "intents_volume_by_dst_token_idx"; +DROP INDEX IF EXISTS "intents_volume_by_token_idx"; + +ALTER TABLE "intents" DROP CONSTRAINT IF EXISTS "intents_dst_token_id_fkey"; +ALTER TABLE "intents" DROP CONSTRAINT IF EXISTS "intents_src_token_id_fkey"; + +ALTER TABLE "intents" + DROP COLUMN IF EXISTS "dst_decimals", + DROP COLUMN IF EXISTS "src_decimals", + DROP COLUMN IF EXISTS "dst_token_id", + DROP COLUMN IF EXISTS "src_token_id"; diff --git a/prisma/migrations/20261002000000_intent_state_pending_variants/down.sql b/prisma/migrations/20261002000000_intent_state_pending_variants/down.sql new file mode 100644 index 00000000..6e00c285 --- /dev/null +++ b/prisma/migrations/20261002000000_intent_state_pending_variants/down.sql @@ -0,0 +1,12 @@ +-- Rollback for 20261002000000_intent_state_pending_variants. +-- +-- PostgreSQL (through v16, the version CI and production run) has no +-- `ALTER TYPE ... DROP VALUE`: enum values cannot be removed once added, and +-- attempting to recreate the enum would require dropping the `intents` table +-- and every other column typed with `IntentState`. This rollback is therefore +-- an explicit no-op — the table list (what the rollback-verification job +-- compares) is unchanged, and rows in the pending_* states are legal values +-- of a *superset* enum, so the database remains queryable either way. +-- +-- Re-running migration.sql re-adds any value that was manually removed. +SELECT 1; diff --git a/prisma/migrations/20261002000000_intent_state_pending_variants/migration.sql b/prisma/migrations/20261002000000_intent_state_pending_variants/migration.sql new file mode 100644 index 00000000..d141c5ea --- /dev/null +++ b/prisma/migrations/20261002000000_intent_state_pending_variants/migration.sql @@ -0,0 +1,15 @@ +-- Issue #385: submitted-but-unconfirmed intent variants. +-- +-- prisma/schema.prisma's `IntentState` enum declares these four values, but +-- the init migration only created the original six — a fresh database would +-- reject `pending_open` / `pending_accepted` / `pending_filled` / +-- `pending_cancelled` writes at runtime even though the generated client +-- accepts them. ALTER TYPE ... ADD VALUE (rather than CREATE TYPE) keeps the +-- statement additive: it never rewrites or locks existing rows. +-- +-- IF NOT EXISTS keeps the migration re-runnable against databases where a +-- earlier partial attempt already added a value. +ALTER TYPE "IntentState" ADD VALUE IF NOT EXISTS 'pending_open'; +ALTER TYPE "IntentState" ADD VALUE IF NOT EXISTS 'pending_accepted'; +ALTER TYPE "IntentState" ADD VALUE IF NOT EXISTS 'pending_filled'; +ALTER TYPE "IntentState" ADD VALUE IF NOT EXISTS 'pending_cancelled'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea66ea02..84341043 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,13 +1,3 @@ -// Prisma schema for vortex-backend. -// This file is the source of truth for `npx prisma generate`; the canonical -// migration history lives in prisma/migrations/. -// -// Issues implemented in this file: -// #410 — src_token_id / dst_token_id FK columns + snapshot decimals -// #411 — DATABASE_REPLICA_URLS env var wired through datasource - -generator client { - provider = "prisma-client-js" // ─── Prisma Schema ──────────────────────────────────────────────────────────── // Database: PostgreSQL (swap to sqlite for local dev / CI without a real DB) // Run `npm run db:generate` after editing this file. @@ -44,6 +34,11 @@ enum IntentState { cancelled expired slashed + // Submitted-but-unconfirmed variants (issue #385). + pending_open + pending_accepted + pending_filled + pending_cancelled } enum SupportedChain { @@ -56,97 +51,16 @@ enum SupportedChain { avalanche } +/// Registry lifecycle. Delisted rows stay in the table so existing intents +/// that already copied the token metadata keep resolving. enum TokenStatus { active paused delisted -} - -// ─── Tokens ────────────────────────────────────────────────────────────────── -model Token { - id String @id @default(cuid()) - address String - symbol String - name String - decimals Int - chain SupportedChain - logoUri String? @map("logo_uri") - priceUsd Float? @map("price_usd") - isStellar Boolean @default(false) @map("is_stellar") - status TokenStatus @default(active) - assetKind String @default("evm") @map("asset_kind") - - // Back-references from intents (#410) - srcIntents Intent[] @relation("src_token") - dstIntents Intent[] @relation("dst_token") - - @@unique([address, chain], name: "address_chain") - @@index([chain]) - @@index([symbol]) - @@map("tokens") + @@map("token_status") } -// ─── Intents ───────────────────────────────────────────────────────────────── - -model Intent { - id String @id @default(cuid()) - intentId String @unique @map("intent_id") - user String - srcChain SupportedChain @map("src_chain") - - // Original JSON blobs retained during expand/contract migration (#410). - // Do NOT remove until the follow-up contract migration is complete. - srcToken Json @map("src_token") - srcAmount String @map("src_amount") - dstToken Json @map("dst_token") - minDstAmount String @map("min_dst_amount") - - // ── #410: FK columns ──────────────────────────────────────────────────────── - // Nullable during the transition period; set by the create path once all - // tokens in the registry are resolvable. The backfill migration populates - // these for existing rows. - srcTokenId String? @map("src_token_id") - dstTokenId String? @map("dst_token_id") - /// Immutable snapshot of src token decimals at intent creation time. - srcDecimals Int? @map("src_decimals") - /// Immutable snapshot of dst token decimals at intent creation time. - dstDecimals Int? @map("dst_decimals") - - // Relations - srcTokenRecord Token? @relation("src_token", fields: [srcTokenId], references: [id]) - dstTokenRecord Token? @relation("dst_token", fields: [dstTokenId], references: [id]) - - quotedDstAmount String? @map("quoted_dst_amount") - acceptedDstAmount String? @map("accepted_dst_amount") - solver String? - state IntentState @default(open) - createdAt Int @map("created_at") - deadline Int - filledAt Int? @map("filled_at") - fillAmount String? @map("fill_amount") - feeAmount String? @map("fee_amount") - txHash String? @map("tx_hash") - slashedAt Int? @map("slashed_at") - slashReason String? @map("slash_reason") - - // Optimistic concurrency (#404) - version Int @default(0) - idempotencyKey String? @unique @map("idempotency_key") - - // Dutch auction (#429) - auction Json? - - // Source-deposit verification (#403) - srcVerified Boolean @default(false) @map("src_verified") - srcTxHash String? @map("src_tx_hash") - srcVerification Json? @map("src_verification") - - // Governance params snapshot at creation - paramsVersion String? @map("params_version") - - // Audit log - auditLog IntentAuditLog[] // ─── Kill-switch scopes (issue #477) ────────────────────────────────────────── // A switch is addressed by exactly one of four mutually-exclusive scopes. // `global` has no chain/token; `chain` sets chain only; `token` sets chain + @@ -175,6 +89,15 @@ enum KillSwitchOperation { onchain } +/// Fill-verification lifecycle (issue #385 / DDL in +/// 20260930000000_fill_verification): `pending` until the source-deposit side +/// settles, then `verified` or `rejected`. +enum FillVerificationState { + pending + verified + rejected +} + // ─── Intent ────────────────────────────────────────────────────────────────── // Represents a cross-chain swap request submitted by a user. // The nested token objects (srcToken / dstToken) are stored as JSONB so the @@ -195,6 +118,22 @@ model Intent { dstToken Json @map("dst_token") /// Minimum acceptable destination amount (base unit string). minDstAmount String @map("min_dst_amount") + + // ── #410: FK columns ──────────────────────────────────────────────────────── + // Nullable during the transition period; set by the create path once all + // tokens in the registry are resolvable. The backfill migration populates + // these for existing rows. + srcTokenId String? @map("src_token_id") + dstTokenId String? @map("dst_token_id") + /// Immutable snapshot of src token decimals at intent creation time. + srcDecimals Int? @map("src_decimals") + /// Immutable snapshot of dst token decimals at intent creation time. + dstDecimals Int? @map("dst_decimals") + + /// Optional off-chain Dutch auction terms (issue #507). + auction Json? @map("auction") + /// Auction price locked when a solver accepts. + acceptedDstAmount String? @map("accepted_dst_amount") /// Best quote from solvers, populated after quoting. quotedDstAmount String? @map("quoted_dst_amount") /// Solver address that accepted / filled this intent. @@ -212,38 +151,41 @@ model Intent { feeAmount String? @map("fee_amount") /// On-chain transaction hash of the Stellar fill transaction. txHash String? @map("tx_hash") + /// Unix epoch seconds – set when state becomes `slashed`. + slashedAt Int? @map("slashed_at") + slashReason String? @map("slash_reason") + /// Optimistic-concurrency version, incremented on every update (issue #405). + version Int @default(0) @map("version") + /// Caller-supplied idempotency key for POST /intents; unique across replicas (issue #404). + idempotencyKey String? @unique @map("idempotency_key") + /// Source-chain escrow deposit confirmed (issue #403); unverified intents are not fillable. + srcVerified Boolean @default(false) @map("src_verified") + /// EVM deposit transaction hash supplied at creation, if any. + srcTxHash String? @map("src_tx_hash") + /// Last verification result: status, block, received amount, detail. + srcVerification Json? @map("src_verification") + + /// USD value of `srcAmount` at creation (issue #440); null when the price + /// was unknown — historical rows are never backfilled. + usdValueAtCreate Float? @map("usd_value_at_create") + /// Fill-verification timestamp written by the fill verifier. + fillVerifiedAt DateTime? @map("fill_verified_at") @db.Timestamptz + /// Fill verification state ("pending" | "verified" | "rejected"). + fillVerificationState FillVerificationState? @map("fill_verification_state") + /// Reason reported when fill verification rejected the fill. + fillVerificationReason String? @map("fill_verification_reason") @@index([user]) @@index([state]) - @@index([solver]) @@index([user, createdAt(sort: Desc)], name: "intents_user_created_idx") @@index([state, createdAt(sort: Desc)], name: "intents_state_created_idx") - // Volume-by-token index for token analytics (#410) - // Partial indexes are expressed in raw SQL migration (Prisma doesn't support WHERE clauses). + // Volume-by-token index for token analytics (#410). + // Partial variants live in raw SQL migration 20261001000000 (Prisma cannot + // express WHERE clauses on indexes). @@index([srcTokenId, createdAt(sort: Desc)], name: "intents_volume_by_token_idx") @@index([dstTokenId, createdAt(sort: Desc)], name: "intents_volume_by_dst_token_idx") @@map("intents") -} -// ─── Solvers ───────────────────────────────────────────────────────────────── - -model Solver { - id String @id @default(cuid()) - address String @unique - name String - bondAmount String @map("bond_amount") - fillsCompleted Int @default(0) @map("fills_completed") - fillsFailed Int @default(0) @map("fills_failed") - totalVolume String @default("0") @map("total_volume") - avgFillTime Float @default(0) @map("avg_fill_time") - isActive Boolean @default(true) @map("is_active") - registeredAt Int @map("registered_at") - lastActiveAt Int @map("last_active_at") - supportedChains Json @map("supported_chains") - supportedTokens Json @map("supported_tokens") - source String? @default("api") - chainUpdatedLedger Int? @map("chain_updated_ledger") - @@map("intents") } // ─── Solver ────────────────────────────────────────────────────────────────── @@ -284,23 +226,6 @@ model Solver { @@map("solvers") } -// ─── Intent audit log ──────────────────────────────────────────────────────── - -model IntentAuditLog { - id BigInt @id @default(autoincrement()) - intentId String @map("intent_id") - timestamp DateTime @default(now()) - toState String @map("to_state") - actor String - reason String - metadata Json? - - intent Intent @relation(fields: [intentId], references: [intentId], onDelete: Cascade) - - @@index([intentId]) - @@index([intentId, timestamp(sort: Desc)]) - @@map("intent_audit_log") -} // ─── IntentAuditLog ────────────────────────────────────────────────────────── // Append-only record of every state transition for an intent (issue #217 / #62). // Queried by intentId to reconstruct the full history of a swap. @@ -324,6 +249,30 @@ model IntentAuditLog { @@map("intent_audit_log") } +// ─── ContractUpgrade ───────────────────────────────────────────────────────── +// Append-only history of detected WASM upgrades of the contracts the backend +// writes to (issue #402). Written by ContractVersionService when a poll sees +// a new hash or an upgrade event is ingested. + +model ContractUpgrade { + id BigInt @id @default(autoincrement()) @map("id") + /// "settlement" | "solverRegistry" + contractName String @map("contract_name") + contractId String @map("contract_id") + previousWasmHash String? @map("previous_wasm_hash") + wasmHash String? @map("wasm_hash") + /// ABI version the new hash maps to; null when unsupported. + abiVersion String? @map("abi_version") + /// "poll" | "event" + source String @map("source") + ledger Int? @map("ledger") + txHash String? @map("tx_hash") + detectedAt DateTime @default(now()) @map("detected_at") @db.Timestamptz + + @@index([contractId, detectedAt], name: "contract_upgrades_contract_idx") + @@map("contract_upgrades") +} + // ─── Token ─────────────────────────────────────────────────────────────────── // Static registry of tokens the protocol supports. // Kept separate so it can be updated without migrations when the token list changes. @@ -341,6 +290,12 @@ model Token { priceUsd Float? @map("price_usd") /// Whether this is a destination-side Stellar token. isStellar Boolean @default(false) @map("is_stellar") + /// active tokens are listed; paused stay listed but are not offered for new + /// intents; delisted are hidden from discovery and still readable by id. + status TokenStatus @default(active) @map("status") + /// evm | stellar-sac | stellar-classic. String so classic and SAC stay distinct + /// without a second database enum. + assetKind String @default("evm") @map("asset_kind") @@unique([address, chain]) @@index([chain]) @@ -610,3 +565,278 @@ model GuardianAction { @@map("guardian_actions") } +// ─── OnchainOutbox ─────────────────────────────────────────────────────────── +// Transactional outbox for on-chain writes (issue #396). A row is inserted in +// the same database transaction as the intent mutation it mirrors, and +// OutboxRelayService submits it to Soroban afterwards — so the DB can never say +// "accepted" while the corresponding transaction silently never went out. +// +// `id` is a monotonically increasing sequence and doubles as the per-intent +// ordering key: a row is only claimable once every earlier row for the same +// intent has reached a terminal success state. + +enum OutboxStatus { + pending + processing + submitted + confirmed + simulated + dead +} + +model OnchainOutbox { + id BigInt @id @default(autoincrement()) @map("id") + /// intents.intent_id this operation belongs to (ordering partition key). + intentId String @map("intent_id") + /// Contract operation, e.g. "create_intent", "accept_intent". + operation String @map("operation") + /// Operation arguments (JSON-safe, bigint amounts as strings). + payload Json @map("payload") + status OutboxStatus @default(pending) @map("status") + /// Number of times this row has been claimed by a relay worker. + attempts Int @default(0) @map("attempts") + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") @db.Timestamptz + /// Lease expiry while status = processing; a crashed worker's row is reclaimed after this. + lockedUntil DateTime? @map("locked_until") @db.Timestamptz + /// Hash of the signed envelope, persisted BEFORE submission (crash idempotency). + envelopeHash String? @map("envelope_hash") + /// Hash of the transaction that was submitted. + txHash String? @map("tx_hash") + lastError String? @map("last_error") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz + + @@index([status, nextAttemptAt], name: "onchain_outbox_claim_idx") + @@index([intentId, id], name: "onchain_outbox_intent_order_idx") + @@map("onchain_outbox") +} + +// ─── PendingSlash ──────────────────────────────────────────────────────────── +// Durable saga state for a solver slash (issue #397): +// detected → challenge_window → submitted → confirmed | cancelled +// The unique constraint on intent_id enforces exactly-once slashing per intent. + +enum PendingSlashState { + detected + challenge_window + submitted + confirmed + cancelled +} + +model PendingSlash { + id String @id @default(uuid()) @map("id") + intentId String @unique @map("intent_id") + solverAddress String @map("solver_address") + reason String @map("reason") + state PendingSlashState @default(detected) @map("state") + /// The intent's fill deadline (unix seconds) that the solver missed. + fillDeadline Int @map("fill_deadline") + detectedAt DateTime @map("detected_at") @db.Timestamptz + /// Slash may not be submitted before this instant. + challengeEndsAt DateTime @map("challenge_ends_at") @db.Timestamptz + attempts Int @default(0) @map("attempts") + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") @db.Timestamptz + /// Lease held by the pipeline worker that is currently verifying/submitting. + lockedUntil DateTime? @map("locked_until") @db.Timestamptz + txHash String? @map("tx_hash") + /// true when the registry client simulated but did not broadcast (dry-run / gated submit). + simulated Boolean @default(false) @map("simulated") + submittedAt DateTime? @map("submitted_at") @db.Timestamptz + confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz + cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz + cancelReason String? @map("cancel_reason") + cancelledBy String? @map("cancelled_by") + /// Fill transaction that cancelled the slash, when cancelled by a fill proof. + fillTxHash String? @map("fill_tx_hash") + lastError String? @map("last_error") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz + + @@index([state, challengeEndsAt], name: "pending_slashes_due_idx") + @@index([solverAddress], name: "pending_slashes_solver_idx") + @@map("pending_slashes") +} + +// ─── API key tiers (issue #441) ─────────────────────────────────────────────── +// API keys authenticate programmatic clients and carry a tier that selects the +// distributed rate-limit quota. Keys are stored only as SHA-256 hashes; the +// plaintext is shown once at creation and never persisted or logged. + +enum ApiKeyTier { + public + integrator + solver + partner +} + +model ApiKey { + id String @id @default(uuid()) @map("id") + /// First 8 characters of the key — non-secret lookup handle. + keyPrefix String @unique @map("key_prefix") + /// SHA-256 hash of the full key (hex-encoded). Never store the plaintext. + keyHash String @map("key_hash") + tier ApiKeyTier @map("tier") + /// Free-text owner label (e.g. "frontend", "solver-bot-1"). + owner String @map("owner") + /// Optional scope strings narrowing what the key may do (e.g. ["intents:read"]). + scopes Json @default("[]") @map("scopes") + /// Unix epoch seconds. + createdAt Int @map("created_at") + /// Unix epoch seconds; null while the key is active. + revokedAt Int? @map("revoked_at") + /// Unix epoch seconds; null for keys that do not expire. + expiresAt Int? @map("expires_at") + lastUsedAt Int? @map("last_used_at") + + @@index([tier]) + @@map("api_keys") +} + +model SolverCredential { + id String @id @default(uuid()) @map("id") + /// First 8 characters of the credential — non-secret lookup handle. + credPrefix String @unique @map("cred_prefix") + /// SHA-256 hash of the credential (hex-encoded). + credHash String @map("cred_hash") + /// Solver on-chain address this credential is bound to. + solverAddress String @map("solver_address") + /// Allowed scope strings, e.g. ["quote:respond", "intents:accept"]. + scopes Json @map("scopes") + /// Optional CIDR / IP allowlist the credential may be presented from. + ipAllowlist Json? @map("ip_allowlist") + /// Unix epoch seconds. + createdAt Int @map("created_at") + /// Unix epoch seconds; null for credentials that do not expire. + expiresAt Int? @map("expires_at") + /// Unix epoch seconds; null while the credential is active. + revokedAt Int? @map("revoked_at") + /// Unix epoch seconds of the last rotation (previous credential revoked). + rotatedAt Int? @map("rotated_at") + lastUsedAt Int? @map("last_used_at") + + @@index([solverAddress]) + @@map("solver_credentials") +} + +// ─── Soroban event ingestion (transactional outbox lineage) ─────────────────── +// Idempotency + dead-letter tables for the versioned decoder registry (#390), +// ledger-gap backfill (#391) and chain-authoritative reconciler (#392). The +// models mirror the authoritative DDL in +// 20260927000000_processed_events_dead_letters (keyed by the RPC event id +// "-"). + +model ProcessedEvent { + id BigInt @id @default(autoincrement()) @map("id") + /// RPC event id "-". + eventId String @map("event_id") + ledger Int @map("ledger") + eventIndex Int @map("event_index") + contractId String @map("contract_id") + /// Decoded topic[0] string. + topic String @map("topic") + txHash String @map("tx_hash") + processedAt DateTime @default(now()) @map("processed_at") @db.Timestamptz + + @@unique([ledger, eventIndex], name: "processed_events_ledger_idx_key") + @@index([ledger], name: "processed_events_ledger_idx") + @@index([contractId], name: "processed_events_contract_idx") + @@map("processed_events") +} + +model DeadLetterEvent { + id BigInt @id @default(autoincrement()) @map("id") + /// RPC event id "-". + eventId String @map("event_id") + ledger Int @map("ledger") + txHash String @map("tx_hash") + /// Raw (undecoded) topic[0] string that failed schema validation. + rawTopic String @map("raw_topic") + /// Validation error message. + error String @map("error") + /// JSON-encoded base64 XDR topic array, kept so operators can replay. + rawXdr String @map("raw_xdr") + occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz + + @@index([ledger], name: "dead_letter_events_ledger_idx") + @@map("dead_letter_events") +} + +// ─── Pending-transaction confirmation (issues #386–#389, PR #509) ──────────── +// DDL: 20260926000000_tx_confirmation_channel_pool_ingestion. + +enum PendingTxStatus { + pending + confirmed + failed + expired +} + +model PendingTransaction { + id BigInt @id @default(autoincrement()) @map("id") + /// The Stellar transaction hash. + txHash String @unique @map("tx_hash") + /// XDR-encoded signed transaction envelope (base64), kept for fee-bump resubmission. + txXdr String @map("tx_xdr") + /// Associated intent identifier (nullable — not every tx is tied to one intent). + intentId String? @map("intent_id") + /// Channel account public key used for submission (nullable for main-key txs). + channelKey String? @map("channel_key") + status PendingTxStatus @default(pending) @map("status") + /// Unix epoch seconds after which tracking is abandoned and the tx is marked expired. + maxTrackUntil Int @map("max_track_until") + /// Number of poll attempts so far. + attempts Int @default(0) @map("attempts") + /// Unix epoch seconds of the next scheduled poll. + nextPollAt Int @default(0) @map("next_poll_at") + /// Fee in stroops of the most recent submission. + lastFeeStroops String? @map("last_fee_stroops") + /// Number of fee-bump escalations applied so far. + feeBumpCount Int @default(0) @map("fee_bump_count") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@index([status, nextPollAt], name: "pending_tx_poll_idx") + @@index([intentId], name: "pending_tx_intent_idx") + @@map("pending_transactions") +} + +model IngestionCursor { + id BigInt @id @default(autoincrement()) @map("id") + network String @map("network") + contractId String @map("contract_id") + /// Last ledger sequence whose events have been fully applied. + lastLedger Int @map("last_ledger") + /// Index of the last applied event within lastLedger (for sub-ledger resume). + lastEventIdx Int @default(0) @map("last_event_idx") + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@unique([network, contractId], name: "ingestion_cursor_uniq") + @@map("ingestion_cursor") +} + +model SignatureNonce { + signer String @map("signer") + nonce String @map("nonce") + expiresAt Int @map("expires_at") + + @@id([signer, nonce]) + @@index([expiresAt]) + @@map("signature_nonces") +} + +// ─── Price history (price-feed worker, issue #566) ──────────────────────────── +// Append-only series backing the extreme-move circuit breaker; `tokens.price_usd` +// holds the latest value, this table keeps the trail. + +model PriceHistory { + id String @id @default(uuid()) @map("id") + /// Token this sample belongs to (cascade-deleted with the token). + tokenId String @map("token_id") + priceUsd Float @map("price_usd") + /// Unix epoch seconds… wall-clock sample time (UTC). + recordedAt DateTime @map("recorded_at") @db.Timestamptz + + @@index([tokenId, recordedAt], name: "price_history_token_idx") + @@map("price_history") +} diff --git a/scripts/README.md b/scripts/README.md index 2f3a9554..629e87d9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -45,6 +45,113 @@ npm run solver:demo # in another, run the bot | `SOLVER_CHAINS` | `stellar,ethereum,base,polygon,arbitrum,optimism,avalanche` | Comma-separated list of chain topics to filter WebSocket intent events | | `MIN_MARGIN_BPS` | `0` | Minimum margin threshold in basis points for filtering unviable intent fills | +The bot's accept gate (state → deadline → chain → margin) is shared with +the simulation harness below — see `tools/simulator/strategies/gate.ts`. + +--- + +# tools/simulator + +Solver simulation & backtesting harness (issue #452). Replays an archived +intent stream against pluggable solver strategies on a **deterministic +simulated clock** — no network, no Nest bootstrap, no wall-clock reads — +and reports **PnL, fill-rate and slash-risk**, optionally across a +fill-window × fee-bps parameter sweep. + +The harness reuses the pure domain modules directly: + +- `src/fees` — `quoteFee` prices every fill under the configured fee bps +- `src/routing` — `RoutingService.buildRoute` gates settlement feasibility +- `src/auctions` — `dutchAuctionPrice` drives auction-aware fills + +`npm run typecheck`/`npm run lint` cover it the usual way; the harness's +suite runs in the unit test project `tools` (`tools/simulator/*.spec.ts`). + +## Usage + +```bash +# Replay an archive with the default margin-aware strategy +npm run simulate -- --input tools/simulator/fixtures/sample-intents.jsonl \ + --prices tools/simulator/fixtures/sample-prices.jsonl + +# Baseline: the naive accept-everything strategy (same gate as scripts/solver-bot.ts) +npm run simulate -- --input archive.jsonl --prices prices.jsonl --strategy always + +# Comparative sweep: fill window × fee bps → markdown table +npm run simulate -- --input archive.jsonl --prices prices.jsonl --sweep \ + --fill-windows 60,120,300 --fee-bps-list 0,5,10 + +# Smoke-run without data (deterministic synthetic archive) + raw report dump +npm run simulate -- --generate 100000 --strategy always --json report.json + +# Full option list +npm run simulate -- --help +``` + +## Data format + +Archived intents are **JSON Lines**, one event per line. Field names +mirror the public `intents` dataset schema (`src/datasets/schemas.ts`), so +exported dataset rows replay with minimal reshaping: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `intentId` | string | ✔ | | +| `createdAt`, `deadline` | int (unix s) | ✔ | `createdAt` positions the simulated clock | +| `srcChain`, `srcAmount`, `minDstAmount` | string | ✔ | amounts in base units | +| `srcTokenSymbol`, `dstTokenSymbol` | string | | enables USD valuation | +| `usdValueAtCreate` | number | | source-leg value captured at creation; preferred over price lookups | +| `auction` | object | | `{ startDstAmount, decayStart, decayEnd }` Dutch auction | +| `event` | `"intent"` \| `"quote_request"` | | dispatch selector (default `intent`) | + +Prices are JSONL snapshots `{"ts","chain","symbol","priceUsd","decimals?"}`; +lookups resolve to the latest snapshot at or before the simulated time. +**Unknown prices are never guessed** — fills with unpriceable legs are +counted but excluded from USD totals (reported as `unpriced fills`). + +Blank lines and `#` comments are ignored in both formats. + +## Strategies & the strategy interface + +Implement `SimulatorStrategy` (`tools/simulator/types.ts`): + +```ts +interface SimulatorStrategy { + readonly name: string; + onIntent(ctx, intent): QuoteDecision | null; // new intent on the feed + onQuoteRequest(ctx, intent): QuoteDecision | null; // RFQ round + onTick(ctx, nowSec): void; // clock advanced +} +``` + +Returning `null` declines; returning `{ dstAmount, fillDelayMs? }` +schedules a fill at `now + fillDelayMs`, which the engine checks against +the intent deadline and the fill-window parameter. `ctx.random()` is a +seeded PRNG, so even randomized strategies replay identically per seed. + +Two reference strategies ship with the harness: + +| Strategy | Behaviour | +|---|---| +| `always` (`AlwaysFillStrategy`) | quotes `minDstAmount` immediately for every gate-passing intent — the exact strategy `scripts/solver-bot.ts` runs live, extracted for offline replay | +| `margin` (`MarginThresholdStrategy`, default) | values both legs via archived prices, quotes the protocol fee, checks routing feasibility, and optionally waits for Dutch-auction decay (`--auction`) before filling | + +## Sweep mode + +`--sweep` replays the same archive once per grid cell +(`--fill-windows` × `--fee-bps-list`, defaults `60,120,300` × `0,5,10`) +and prints one comparative markdown row per cell: fill rate, capture, +slashes, PnL, fees, volume. `--json` dumps the full structured sweep. + +## Determinism & performance + +The engine consumes only archive timestamps (never `Date.now()`), and all +randomness flows through the seeded PRNG — so identical +(archive, prices, strategy, params, seed) inputs yield byte-identical +reports. This is asserted by the determinism tests in +`tools/simulator/engine.spec.ts`, and `tools/simulator/perf.spec.ts` +enforces the issue's budget: **1M intents replay in under 5 minutes**. + --- # scripts/backfill-events.ts diff --git a/scripts/solver-bot.ts b/scripts/solver-bot.ts index 88444899..c4363ea4 100644 --- a/scripts/solver-bot.ts +++ b/scripts/solver-bot.ts @@ -2,6 +2,7 @@ import WebSocket from "ws"; import { Keypair } from "@stellar/stellar-sdk"; import { buildAcceptMessage, buildFillMessage } from "../src/common/stellar-signature"; +import { attemptGateReason } from "../tools/simulator/strategies/gate"; const API_BASE = process.env.API_BASE ?? "http://localhost:4000"; const WS_URL = process.env.WS_URL ?? "ws://localhost:4000/ws"; @@ -104,13 +105,20 @@ async function fillIntent(intentId: string, minDstAmount: string): Promise async function tryFillOpenIntent(intent: Intent): Promise { const now = Math.floor(Date.now() / 1000); - if (intent.state !== "open" || intent.deadline <= now) return; - if (!SOLVER_CHAINS.includes(intent.srcChain)) { + // Shared accept gate — the same one tools/simulator's always-fill + // reference strategy runs in replay (issue #452). Live bot and harness + // agree on which intents get attempted. + const reason = attemptGateReason(intent, { + chains: SOLVER_CHAINS, + minMarginBps: MIN_MARGIN_BPS, + nowSec: now, + }); + if (reason === "state" || reason === "deadline") return; + if (reason === "chain") { console.log(`[solver-bot] skipping ${intent.intentId} on ${intent.srcChain} (not subscribed)`); return; } - - if (MIN_MARGIN_BPS > 0 && Number(intent.minDstAmount) < 1_000_000 * (MIN_MARGIN_BPS / 10000)) { + if (reason === "margin") { console.log(`[solver-bot] skipped ${intent.intentId} below min margin ${MIN_MARGIN_BPS} bps`); return; } diff --git a/src/abuse/abuse-detector.guard.ts b/src/abuse/abuse-detector.guard.ts index d3188d64..bbf3e023 100644 --- a/src/abuse/abuse-detector.guard.ts +++ b/src/abuse/abuse-detector.guard.ts @@ -27,7 +27,6 @@ import { HttpException, Injectable, Logger, - TooManyRequestsException, } from "@nestjs/common"; import { Request } from "express"; import { AbuseScoreService } from "./abuse-score.service"; @@ -45,6 +44,19 @@ class PreconditionRequiredException extends HttpException { } } +/** + * 429 Too Many Requests for the throttle action. + * + * Declared locally because the installed `@nestjs/common` build does not + * export a `TooManyRequestsException`; mirroring {@link PreconditionRequiredException} + * keeps the guard independent of that export. + */ +class TooManyRequestsException extends HttpException { + constructor(message: string) { + super({ statusCode: 429, error: "Too Many Requests", message }, 429); + } +} + @Injectable() export class AbuseDetectorGuard implements CanActivate { private readonly logger = new Logger(AbuseDetectorGuard.name); diff --git a/src/abuse/abuse-score.service.spec.ts b/src/abuse/abuse-score.service.spec.ts index 778afb00..b51a1993 100644 --- a/src/abuse/abuse-score.service.spec.ts +++ b/src/abuse/abuse-score.service.spec.ts @@ -13,6 +13,7 @@ import { ConfigService } from "@nestjs/config"; import { AbuseScoreService } from "./abuse-score.service"; import { AbuseContext } from "./abuse.types"; +import type { AppConfig } from "../config/configuration"; // --------------------------------------------------------------------------- // Minimal ioredis mock — we stub only the methods used by the scorer. @@ -133,10 +134,10 @@ class FakeRedis { // Helpers // --------------------------------------------------------------------------- -function makeConfigService(): ConfigService { +function makeConfigService(): ConfigService { return { get: (_key: string) => "redis://localhost:6379", - } as unknown as ConfigService; + } as unknown as ConfigService; } function makeCtx(overrides: Partial = {}): AbuseContext { @@ -153,7 +154,7 @@ function makeCtx(overrides: Partial = {}): AbuseContext { } function buildService(fakeRedis: FakeRedis): AbuseScoreService { - const svc = new AbuseScoreService(makeConfigService() as ConfigService); + const svc = new AbuseScoreService(makeConfigService()); // Replace the ioredis instance with our fake (svc as unknown as { redis: FakeRedis }).redis = fakeRedis; return svc; diff --git a/src/abuse/allowlist.service.ts b/src/abuse/allowlist.service.ts index 1a37d1d7..728c15d8 100644 --- a/src/abuse/allowlist.service.ts +++ b/src/abuse/allowlist.service.ts @@ -39,8 +39,9 @@ export class AllowlistService { for (const entry of raw.split(",").map((e) => e.trim()).filter(Boolean)) { if (entry.startsWith("key:")) { keyDigests.add(this.digest(entry.slice(4))); - } else if (entry.startsWith("G") && entry.length >= 32) { - // Stellar address heuristic: starts with G and is at least 32 chars + } else if (entry.startsWith("G")) { + // Stellar account IDs start with G (contracts C, muxed M never do), + // so a G-prefix alone classifies the entry as an address. addresses.add(entry.toLowerCase()); } else { // Everything else is treated as an IP / CIDR diff --git a/src/archival/archival.service.ts b/src/archival/archival.service.ts index 9c7d0736..ca764320 100644 --- a/src/archival/archival.service.ts +++ b/src/archival/archival.service.ts @@ -106,7 +106,7 @@ export class ArchivalService { let cursor: string | undefined; let fileIndex = 0; - while (true) { + for (;;) { const batch = await this.prisma.withStatsTimeout((tx) => (tx as unknown as typeof this.prisma).intent.findMany({ where: { @@ -204,7 +204,7 @@ export class ArchivalService { let auditCursor = 0n; let auditFileIndex = 0; - while (true) { + for (;;) { const auditBatch = await this.prisma.withStatsTimeout((tx) => (tx as unknown as typeof this.prisma).intentAuditLog.findMany({ where: { diff --git a/src/archival/parquet-writer.ts b/src/archival/parquet-writer.ts index 2e06b2e6..d7062774 100644 --- a/src/archival/parquet-writer.ts +++ b/src/archival/parquet-writer.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { readFileSync, unlinkSync } from "node:fs"; import { randomBytes } from "node:crypto"; // @dsnp/parquetjs is aliased as "parquetjs" in package.json -// eslint-disable-next-line @typescript-eslint/no-require-imports +// eslint-disable-next-line @typescript-eslint/no-var-requires const parquet = require("parquetjs"); /** diff --git a/src/common/http-egress/http-egress.service.ts b/src/common/http-egress/http-egress.service.ts index 8cffc8a4..345c60e7 100644 --- a/src/common/http-egress/http-egress.service.ts +++ b/src/common/http-egress/http-egress.service.ts @@ -249,7 +249,8 @@ export class HttpEgressService { if ([301, 302, 303, 307, 308].includes(response.statusCode)) { redirects++; - const location = response.headers['location']; + const locationHeader = response.headers['location']; + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader; if (!location) { throw new Error('Redirect without Location header'); } diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index 63ab1c84..7d5d69ad 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -12,8 +12,6 @@ import { Keypair } from "@stellar/stellar-sdk"; import { UnauthorizedException } from "@nestjs/common"; import { createHash } from "node:crypto"; -import { Keypair } from "@stellar/stellar-sdk"; -import { UnauthorizedException } from "@nestjs/common"; export const INTENT_SIGNATURE_CLOCK_SKEW_SECONDS = 30; export const MAX_INTENT_SIGNATURE_TTL_SECONDS = 900; @@ -40,33 +38,6 @@ function buildV2IntentMessage( return `vortex:${context.network}:${action}:${intentId}:${context.nonce}:${context.expiresAt}:${payloadHash}`; } -/** - * Verify that `signature` (base64) over `message` (utf-8) was produced by - * the private key corresponding to `publicKey` (Stellar G-address). - * - * Throws UnauthorizedException on any failure so callers can let it propagate - * straight to the HTTP layer. - */ -export function verifyStellarSignature( - publicKey: string, - message: string, - signature: string, -): void { - try { - const keypair = Keypair.fromPublicKey(publicKey); - const messageBytes = Buffer.from(message, "utf8"); - const signatureBytes = Buffer.from(signature, "base64"); - if (!keypair.verify(messageBytes, signatureBytes)) { - throw new UnauthorizedException("Invalid Stellar signature"); - } - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - throw new UnauthorizedException("Invalid Stellar signature"); - } -} - /** * Verify that `signature` (base64) over `message` (utf-8) was produced by * the private key corresponding to `publicKey` (Stellar G-address). @@ -102,8 +73,6 @@ export function buildCancelMessage(intentId: string, context?: IntentSignatureCo return `cancel:${intentId}`; } - return `cancel:${intentId}`; -} /** * Build the canonical message that an intent owner must sign to amend it. @@ -132,8 +101,6 @@ export function buildAcceptMessage(intentId: string, solver: string, context?: I return `accept:${intentId}:${solver}`; } - return `accept:${intentId}:${solver}`; -} /** * Build the canonical message that a solver must sign to fill an intent. @@ -153,8 +120,6 @@ export function buildFillMessage( } return `fill:${intentId}:${solver}`; } - return `fill:${intentId}:${solver}`; -} /** * Build the canonical message that a solver must sign to register. @@ -163,6 +128,14 @@ export function buildRegisterMessage(address: string): string { return `register:${address}`; } +/** + * Canonical message a solver signs to prove a fill landed in time and cancel + * a pending slash during its challenge window (issue #397). + */ +export function buildFillProofMessage(intentId: string, solver: string, txHash: string): string { + return `fill-proof:${intentId}:${solver}:${txHash}`; +} + /** * Build the canonical message that a solver must sign to change status. */ @@ -203,16 +176,3 @@ export function buildDisputeReviewMessage(disputeId: string): string { export function buildDisputeDecisionMessage(disputeId: string, resolution: string, reason: string): string { return `dispute-decision:${disputeId}:${resolution}:${reason}`; } - -/** - * Build the canonical message that a solver must sign to update their mutable - * profile fields (name / supportedChains / supportedTokens / avgFillTime). - * - * Signing over just the address is sufficient here: it proves control of the - * account whose profile is being edited, and the request body is already - * constrained by the DTO whitelist so no immutable field can ride along. - */ -export function buildUpdateSolverMessage(address: string): string { - return `update-solver:${address}`; -} - diff --git a/src/common/validators/is-valid-address.validator.ts b/src/common/validators/is-valid-address.validator.ts index cd299259..fed65fbc 100644 --- a/src/common/validators/is-valid-address.validator.ts +++ b/src/common/validators/is-valid-address.validator.ts @@ -1,25 +1,35 @@ import { registerDecorator, ValidationOptions, ValidationArguments } from "class-validator"; +import { SupportedChain } from "../../intents/intents.types"; -export function IsValidAddress(validationOptions?: ValidationOptions) { +/** Validation options plus the chain whose address format should be enforced. */ +export interface IsValidAddressOptions extends ValidationOptions { + /** + * Chain whose address format to check. When omitted the decorator falls + * back to the sibling `srcChain` property of the object under validation. + */ + chain?: SupportedChain; +} + +export function IsValidAddress(validationOptions?: IsValidAddressOptions) { return function (object: object, propertyName: string) { registerDecorator({ name: "isValidAddress", target: object.constructor, - propertyName: propertyName, + propertyName, options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { - const srcChain = (args.object as any).srcChain; - - if (srcChain === "stellar") { + const chain = validationOptions?.chain ?? (args.object as any).srcChain; + + if (chain === "stellar") { return typeof value === "string" && /^G[A-Z2-7]{55}$/.test(value); } - + return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value); }, defaultMessage(args: ValidationArguments) { - const srcChain = (args.object as any).srcChain; - if (srcChain === "stellar") { + const chain = validationOptions?.chain ?? (args.object as any).srcChain; + if (chain === "stellar") { return "Stellar addresses must be 56 characters"; } return "EVM addresses must be 42 characters starting with 0x"; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 18648a68..02cbf4a9 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,338 +1,3 @@ -import { SupportedChain } from "../intents/intents.types"; - -// ─── Chain deadline defaults ────────────────────────────────────────────────── - -/** - * Chain-specific default intent deadlines (seconds from creation). - * Stellar settles in ~5 s; EVM chains have longer finality windows. - */ -export const CHAIN_DEADLINE_DEFAULTS: Partial> = { - stellar: 300, - ethereum: 1800, - base: 900, - polygon: 900, - arbitrum: 900, - optimism: 900, - avalanche: 900, -}; - -export const DEFAULT_DEADLINE_SECONDS = 1800; - -/** - * Chain-specific fill-window defaults (seconds from accept to fill deadline). - * Shorter chains can fill faster. - */ -export const CHAIN_FILL_WINDOW_DEFAULTS: Partial> = { - stellar: 120, - ethereum: 600, - base: 300, - polygon: 300, - arbitrum: 300, - optimism: 300, - avalanche: 300, -}; - -export const DEFAULT_FILL_WINDOW_SECONDS = 600; - -// ─── AppConfig ──────────────────────────────────────────────────────────────── - -export interface AppConfig { - nodeEnv: "development" | "production" | "test"; - port: number; - corsOrigin: string; - databaseUrl: string; - /** Comma-separated read-replica URLs (#411). Blank = primary only. */ - databaseReplicaUrls: string; - /** Maximum replica lag (ms) before a replica is bypassed (#411). */ - maxReplicaLagMs: number; - - intentRetentionDays: number; - intentRetentionSweepMs: number; - onchainIntentsEnabled: boolean; - onchainDryRun: boolean; - intentsStore: "memory" | "dual" | "postgres"; - intentsVerifyIntervalMs: number; - - stellar: { - network: "testnet" | "futurenet" | "mainnet"; - sorobanRpcUrl: string; - sorobanRpcUrls: string; - archivalRpcUrl: string; - horizonUrl: string; - signerSecretKey: string; - settlementContractId: string; - solverRegistryContractId: string; - treasuryAddress: string; - sorobanSigningKey: string; - }; - - ws: { - maxConnections: number; - backplane: string; - maxPayloadBytes: number; - maxConnectionsPerIp: number; - trustProxyHops: number; - rateLimitPerSec: number; - rateLimitBurst: number; - rateLimitMaxViolations: number; - outboundQueueMax: number; - outboundBufferBytes: number; - slowConsumerPolicy: string; - drainTimeoutMs: number; - }; - - sse: { - heartbeatMs: number; - maxBufferBytes: number; - }; - - redis: { - url: string; - }; - - jobs: { - driver: "memory" | "bullmq"; - shutdownTimeoutMs: number; - processRole: "api" | "worker" | "all"; - }; - - killswitch: { - operatorToken: string; - redisUrl: string; - pollMs: number; - persistence: "memory" | "prisma"; - }; - - auth: { - jwtSecret: string; - }; - - evmRpcUrls: Record; - evmDepositVerificationEnabled: boolean; - evmEscrowAddresses: Record; - evmTransferFeeTolerance: number; - evmLogLookbackBlocks: number; - - flags: { - pubsub: "memory" | "redis"; - refreshMs: number; - overrides: string; - }; - - shadow: { - enabled: boolean; - sampleRate: number; - queueMax: number; - concurrency: number; - sourceAccount: string; - }; - - health: { - checkIntervalMs: number; - readyFailureThreshold: number; - readySuccessThreshold: number; - eventLoopMaxLagMs: number; - serviceRoles: string; - }; - - governance: { - paramsContractId: string; - paramsPollIntervalMs: number; - }; - - leaderElection: { - enabled: boolean; - heartbeatMs: number; - }; - - metrics: { - token: string; - }; - - sentry: { - dsn: string; - }; - - log: { - level: string; - serviceName: string; - shippingEnabled: boolean; - shippingHost: string; - shippingPort: number; - shippingPath: string; - shippingSsl: boolean; - }; - - // #413 — Cold-storage archival - archival: { - enabled: boolean; - bucketName: string; - endpoint: string; - region: string; - accessKeyId: string; - secretAccessKey: string; - retentionDays: number; - partitionPrefix: string; - maxRowsPerFile: number; - }; - - // Cursor HMAC secret (#412) - cursorHmacSecret: string; -} - -// ─── Factory function ───────────────────────────────────────────────────────── - -export default function configuration(): AppConfig { - const e = process.env; - - const parseJson = (raw: string | undefined, fallback: T): T => { - if (!raw) return fallback; - try { - return JSON.parse(raw) as T; - } catch { - return fallback; - } - }; - - return { - nodeEnv: (e.NODE_ENV ?? "development") as AppConfig["nodeEnv"], - port: parseInt(e.PORT ?? "4000", 10), - corsOrigin: e.CORS_ORIGIN ?? "*", - databaseUrl: e.DATABASE_URL ?? "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", - databaseReplicaUrls: e.DATABASE_REPLICA_URLS ?? "", - maxReplicaLagMs: parseInt(e.MAX_REPLICA_LAG_MS ?? "5000", 10), - - intentRetentionDays: parseInt(e.INTENT_RETENTION_DAYS ?? "30", 10), - intentRetentionSweepMs: parseInt(e.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), - onchainIntentsEnabled: e.ONCHAIN_INTENTS_ENABLED === "true", - onchainDryRun: e.ONCHAIN_DRY_RUN !== "false", - intentsStore: (e.INTENTS_STORE ?? e.INTENTS_PERSISTENCE ?? "memory") as AppConfig["intentsStore"], - intentsVerifyIntervalMs: parseInt(e.INTENTS_VERIFY_INTERVAL_MS ?? "60000", 10), - - stellar: { - network: (e.STELLAR_NETWORK ?? "testnet") as AppConfig["stellar"]["network"], - sorobanRpcUrl: e.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", - sorobanRpcUrls: e.SOROBAN_RPC_URLS ?? "", - archivalRpcUrl: e.ARCHIVAL_RPC_URL ?? "", - horizonUrl: e.HORIZON_URL ?? "https://horizon-testnet.stellar.org", - signerSecretKey: e.STELLAR_SIGNER_SECRET_KEY ?? "", - settlementContractId: e.SETTLEMENT_CONTRACT_ID ?? "", - solverRegistryContractId: e.SOLVER_REGISTRY_CONTRACT_ID ?? "", - treasuryAddress: e.TREASURY_ADDRESS ?? "", - sorobanSigningKey: e.SOROBAN_SIGNING_KEY ?? "", - }, - - ws: { - maxConnections: parseInt(e.WS_MAX_CONNECTIONS ?? "1000", 10), - backplane: e.WS_BACKPLANE ?? "memory", - maxPayloadBytes: parseInt(e.WS_MAX_PAYLOAD_BYTES ?? "16384", 10), - maxConnectionsPerIp: parseInt(e.WS_MAX_CONNECTIONS_PER_IP ?? "20", 10), - trustProxyHops: parseInt(e.WS_TRUST_PROXY_HOPS ?? "0", 10), - rateLimitPerSec: parseInt(e.WS_RATE_LIMIT_PER_SEC ?? "10", 10), - rateLimitBurst: parseInt(e.WS_RATE_LIMIT_BURST ?? "20", 10), - rateLimitMaxViolations: parseInt(e.WS_RATE_LIMIT_MAX_VIOLATIONS ?? "5", 10), - outboundQueueMax: parseInt(e.WS_OUTBOUND_QUEUE_MAX ?? "1000", 10), - outboundBufferBytes: parseInt(e.WS_OUTBOUND_BUFFER_BYTES ?? "1048576", 10), - slowConsumerPolicy: e.WS_SLOW_CONSUMER_POLICY ?? "drop_oldest", - drainTimeoutMs: parseInt(e.WS_DRAIN_TIMEOUT_MS ?? "25000", 10), - }, - - sse: { - heartbeatMs: parseInt(e.SSE_HEARTBEAT_MS ?? "15000", 10), - maxBufferBytes: parseInt(e.SSE_MAX_BUFFER_BYTES ?? "1048576", 10), - }, - - redis: { - url: e.REDIS_URL ?? "redis://localhost:6379", - }, - - jobs: { - driver: (e.JOBS_DRIVER ?? "memory") as AppConfig["jobs"]["driver"], - shutdownTimeoutMs: parseInt(e.JOBS_SHUTDOWN_TIMEOUT_MS ?? "25000", 10), - processRole: (e.PROCESS_ROLE ?? "all") as AppConfig["jobs"]["processRole"], - }, - - killswitch: { - operatorToken: e.KILLSWITCH_OPERATOR_TOKEN ?? "", - redisUrl: e.KILLSWITCH_REDIS_URL ?? "", - pollMs: parseInt(e.KILLSWITCH_POLL_MS ?? "2000", 10), - persistence: (e.KILLSWITCH_PERSISTENCE ?? "memory") as AppConfig["killswitch"]["persistence"], - }, - - auth: { - jwtSecret: e.AUTH_JWT_SECRET ?? "", - }, - - evmRpcUrls: parseJson>(e.EVM_RPC_URLS, {}), - evmDepositVerificationEnabled: e.EVM_DEPOSIT_VERIFICATION_ENABLED === "true", - evmEscrowAddresses: parseJson>(e.EVM_ESCROW_ADDRESSES, {}), - evmTransferFeeTolerance: parseInt(e.EVM_TRANSFER_FEE_TOLERANCE_BPS ?? "0", 10), - evmLogLookbackBlocks: parseInt(e.EVM_LOG_LOOKBACK_BLOCKS ?? "10000", 10), - - flags: { - pubsub: (e.FLAGS_PUBSUB ?? "memory") as AppConfig["flags"]["pubsub"], - refreshMs: parseInt(e.FLAGS_REFRESH_MS ?? "30000", 10), - overrides: e.FLAG_OVERRIDES ?? "", - }, - - shadow: { - enabled: e.SHADOW_MODE_ENABLED === "true", - sampleRate: parseFloat(e.SHADOW_SAMPLE_RATE ?? "1"), - queueMax: parseInt(e.SHADOW_QUEUE_MAX ?? "256", 10), - concurrency: parseInt(e.SHADOW_CONCURRENCY ?? "4", 10), - sourceAccount: e.SHADOW_SOURCE_ACCOUNT ?? "", - }, - - health: { - checkIntervalMs: parseInt(e.HEALTH_CHECK_INTERVAL_MS ?? "5000", 10), - readyFailureThreshold: parseInt(e.HEALTH_READY_FAILURE_THRESHOLD ?? "3", 10), - readySuccessThreshold: parseInt(e.HEALTH_READY_SUCCESS_THRESHOLD ?? "2", 10), - eventLoopMaxLagMs: parseInt(e.HEALTH_EVENT_LOOP_MAX_LAG_MS ?? "1000", 10), - serviceRoles: e.SERVICE_ROLES ?? "api,ws,worker", - }, - - governance: { - paramsContractId: e.PARAMS_CONTRACT_ID ?? "", - paramsPollIntervalMs: parseInt(e.PARAMS_POLL_INTERVAL_MS ?? "30000", 10), - }, - - leaderElection: { - enabled: e.LEADER_ELECTION_ENABLED === "true", - heartbeatMs: parseInt(e.LEADER_ELECTION_HEARTBEAT_MS ?? "5000", 10), - }, - - metrics: { - token: e.METRICS_TOKEN ?? "", - }, - - sentry: { - dsn: e.SENTRY_DSN ?? "", - }, - - log: { - level: e.LOG_LEVEL ?? "debug", - serviceName: e.LOG_SERVICE_NAME ?? "vortex-backend", - shippingEnabled: e.LOG_SHIPPING_ENABLED === "true", - shippingHost: e.LOG_SHIPPING_HOST ?? "", - shippingPort: parseInt(e.LOG_SHIPPING_PORT ?? "514", 10), - shippingPath: e.LOG_SHIPPING_PATH ?? "/", - shippingSsl: e.LOG_SHIPPING_SSL === "true", - }, - - archival: { - enabled: e.ARCHIVAL_ENABLED === "true", - bucketName: e.ARCHIVAL_BUCKET_NAME ?? "vortex-archives", - endpoint: e.ARCHIVAL_S3_ENDPOINT ?? "", - region: e.ARCHIVAL_S3_REGION ?? "us-east-1", - accessKeyId: e.ARCHIVAL_S3_ACCESS_KEY_ID ?? "", - secretAccessKey: e.ARCHIVAL_S3_SECRET_ACCESS_KEY ?? "", - retentionDays: parseInt(e.ARCHIVAL_RETENTION_DAYS ?? "30", 10), - partitionPrefix: e.ARCHIVAL_PARTITION_PREFIX ?? "date=", - maxRowsPerFile: parseInt(e.ARCHIVAL_MAX_ROWS_PER_FILE ?? "100000", 10), - }, - - cursorHmacSecret: e.CURSOR_HMAC_SECRET ?? "dev-cursor-hmac-secret-do-not-use-in-prod", - }; export type FeePercentile = | "min" | "mode" @@ -430,10 +95,37 @@ export const NETWORK_PASSPHRASES: Record>; + /** Per-chain escrow contract address emitting `Deposited`. */ + escrowAddresses: Partial>; + /** + * Maximum shortfall between the escrow's received amount and srcAmount, in + * basis points — accommodates fee-on-transfer tokens. 0 = exact. + */ + transferFeeToleranceBps: number; + /** How far back to search for the Deposited log when no srcTxHash is given. */ + logLookbackBlocks: number; +} + export interface AppConfig { nodeEnv: string; port: number; databaseUrl: string; + /** Comma-separated read-replica URLs (#411). Blank = primary only. */ + databaseReplicaUrls: string; + /** Maximum replica lag (ms) before a replica is bypassed (#411). */ + maxReplicaLagMs: number; stellar: { network: "testnet" | "futurenet" | "mainnet"; sorobanRpcUrl: string; @@ -448,13 +140,24 @@ export interface AppConfig { signingKey: string; /** Fee percentile to use when estimating Soroban inclusion fees. */ feePercentile: FeePercentile; + /** Maximum fee in stroops for fee-bump escalation (#388). */ + maxFeeStroops: number; + /** Number of channel accounts in the pool (#387). */ + channelPoolSize: number; + /** Comma-separated list of channel account secret keys (#387). */ + channelSecretKeys: string; }; treasury: { address: string; }; onchainIntentsEnabled: boolean; legacyStellarSignatures: boolean; - evm: { + /** + * EVM-side configuration: the deposit-verification knobs (issues #402-#405) + * intersected with the signature-verification tables used by + * `EvmSignatureVerifier` (RPC allowlist + per-chain escrow addresses). + */ + evm: EvmVerificationConfig & { rpcAllowlist: string[]; chains: Record< "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche", @@ -463,7 +166,10 @@ export interface AppConfig { }; intentRetentionDays: number; intentRetentionSweepMs: number; - quoteAuctionWindowMs: number; + /** Dual-write consistency-verification sweep interval (issue #404). */ + intentsVerifyIntervalMs: number; + /** Low-frequency sweeper that catches deadline jobs the queue did not run. */ + safetySweepIntervalMs: number; /** * Dry-run flag for on-chain write paths (issue #260). * @@ -516,34 +222,7 @@ export interface AppConfig { */ pollMs: number; }; -lane is never open. - */ - operatorToken: string; - /** - * Redis URL used for cross-replica pause propagation. Empty falls back to - * database polling only, which still meets the propagation budget. - */ - redisUrl: string; - /** - * Interval (ms) for the `max_updated_at` probe that backstops Redis pub/sub. - * Worst-case propagation delay is roughly this value, so it must stay - * comfortably under the 5 s propagation requirement. - */ - pollMs: number; - }; - /** - * Shadow-mode divergence monitor (issue #401). - * - * Runs read-only on-chain simulations of every intent state transition in - * parallel with the authoritative off-chain path and reports where the two - * disagree. See docs/runbooks/onchain-cutover.md for the go/no-go threshold. - */ - shadow: { - /** Master switch. When false, `ShadowService.observe` is a no-op. */ - enabled: boolean; - /** Fraction of transitions to simulate, in `[0, 1]`. `1` = every one. */ - sampleR /** * Shadow-mode divergence monitor (issue #401). * @@ -585,48 +264,6 @@ lane is never open. paramsPollIntervalMs: number; }; - governance: { - /** - * On-chain governance / parameters contract ID. - * When set, ProtocolParamsService reads current + scheduled parameters - * from this contract and exposes them via GET /api/v1/params. - * Leave blank to use code / env defaults only. - */ - paramsContractId: string; - /** - * How often (in milliseconds) to poll the parameters contract for changes. - * Default: 30 000 ms (30 s). - */ - paramsPollIntervalMs: number; - }; - governance: { - /** - * On-chain governance / parameters contract ID. - * When set, ProtocolParamsService reads current + scheduled parameters - * from this contract and exposes them via GET /api/v1/params. - * Leave blank to use code / env defaults only. - */ - paramsContractId: string; - /** - * How often (in milliseconds) to poll the parameters contract for changes. - * Default: 30 000 ms (30 s). - */ - paramsPollIntervalMs: number; - }; - leaderElection: { - /** When false, all workers run unconditionally (pre-election behaviour). */ - enabled: boolean; - /** Heartbeat interval in ms (default 5000). */ - heartbeatMs: number; - }; - /** - * Process role (issue #494). Producers may enqueue jobs from any role; - * queue workers only run when the role is "worker" or "all". - */ - processRole: "api" | "worker" | "all"; - jobs: { - /** "memory" (single-process, dev/test) or "bullmq" (Redis-backed, durable). */ - drive leaderElection: { /** When false, all workers run unconditionally (pre-election behaviour). */ enabled: boolean; @@ -738,6 +375,61 @@ lane is never open. /** Soroban RPC endpoints probed for quorum (majority must be healthy). */ rpcHealthUrls: string[]; }; + /** Solver reputation scoring (issue #444, RFC 0003). */ + reputation: { + weights: { + fillRate: number; + latency: number; + slashes: number; + quoteHonour: number; + volume: number; + }; + /** Exponential decay half-life in seconds. */ + decayHalflifeSeconds: number; + /** Beta-distribution priors for the fill-rate Bayesian lower bound. */ + bayesAlpha: number; + bayesBeta: number; + /** Volume-component knee in USD-equivalent notional (see RFC 0003). */ + volumeLambdaUsd: number; + /** Trailing snapshot history exposed by /reputation (days, 1..365). */ + historyWindowDays: number; + }; + /** Transactional outbox relay (issue #454). */ + outbox: { + /** Kill switch for the relay worker. Rows keep accumulating while false. */ + relayEnabled: boolean; + relayIntervalMs: number; + batchSize: number; + /** Claims after which a row is moved to `dead` and alerted on. */ + maxAttempts: number; + /** + * Processing lease. Must exceed the signed transaction's time bound so a + * reclaimed row whose envelope is NOT_FOUND can be safely resubmitted. + */ + leaseSeconds: number; + }; + /** On-chain slashing saga (issue #397). */ + slashing: { + /** Delay between detection and broadcast during which a slash can be cancelled. */ + challengeWindowSeconds: number; + /** Grace added to the fill deadline when judging whether a fill landed in time. */ + clockSkewToleranceSeconds: number; + /** Failed submissions after which the slash is cancelled and compensated. */ + maxSubmitAttempts: number; + }; + + // #413 — Cold-storage archival + archival: { + enabled: boolean; + bucketName: string; + endpoint: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + retentionDays: number; + partitionPrefix: string; + maxRowsPerFile: number; + }; } export default (): AppConfig => ({ @@ -746,6 +438,8 @@ export default (): AppConfig => ({ databaseUrl: process.env.DATABASE_URL ?? "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", + databaseReplicaUrls: process.env.DATABASE_REPLICA_URLS ?? "", + maxReplicaLagMs: parseInt(process.env.MAX_REPLICA_LAG_MS ?? "5000", 10), stellar: { network: (process.env.STELLAR_NETWORK ?? "testnet") as AppConfig["stellar"]["network"], sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", @@ -755,6 +449,9 @@ export default (): AppConfig => ({ signerSecretKey: process.env.STELLAR_SIGNER_SECRET_KEY ?? "", signingKey: process.env.SOROBAN_SIGNING_KEY ?? "", feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile, + maxFeeStroops: parseInt(process.env.SOROBAN_MAX_FEE_STROOPS ?? "1000000", 10), + channelPoolSize: parseInt(process.env.CHANNEL_POOL_SIZE ?? "8", 10), + channelSecretKeys: process.env.CHANNEL_SECRET_KEYS ?? "", }, treasury: { address: process.env.TREASURY_ADDRESS ?? "", @@ -772,10 +469,16 @@ export default (): AppConfig => ({ optimism: { chainId: 10, rpcUrl: process.env.OPTIMISM_RPC_URL ?? "", escrowAddress: process.env.OPTIMISM_ESCROW_ADDRESS ?? "" }, avalanche: { chainId: 43114, rpcUrl: process.env.AVALANCHE_RPC_URL ?? "", escrowAddress: process.env.AVALANCHE_ESCROW_ADDRESS ?? "" }, }, + depositVerificationEnabled: (process.env.EVM_DEPOSIT_VERIFICATION_ENABLED ?? "false") === "true", + rpcUrls: parseJsonMap(process.env.EVM_RPC_URLS), + escrowAddresses: parseJsonMap(process.env.EVM_ESCROW_ADDRESSES), + transferFeeToleranceBps: parseInt(process.env.EVM_TRANSFER_FEE_TOLERANCE_BPS ?? "0", 10), + logLookbackBlocks: parseInt(process.env.EVM_LOG_LOOKBACK_BLOCKS ?? "10000", 10), }, intentRetentionDays: parseInt(process.env.INTENT_RETENTION_DAYS ?? "30", 10), intentRetentionSweepMs: parseInt(process.env.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), - quoteAuctionWindowMs: parseInt(process.env.QUOTE_AUCTION_WINDOW_MS ?? "300", 10), + intentsVerifyIntervalMs: parseInt(process.env.INTENTS_VERIFY_INTERVAL_MS ?? "60000", 10), + safetySweepIntervalMs: parseInt(process.env.SAFETY_SWEEP_INTERVAL_MS ?? "300000", 10), // Default to dry-run (true) outside production; in production the value must // be explicitly set (validated by envValidationSchema). onchainDryRun: process.env.ONCHAIN_DRY_RUN !== undefined @@ -892,8 +595,95 @@ export default (): AppConfig => ({ .map((u) => u.trim()) .filter(Boolean), }, + reputation: buildReputationConfig(process.env), + outbox: { + relayEnabled: (process.env.OUTBOX_RELAY_ENABLED ?? "true") === "true", + relayIntervalMs: parseInt(process.env.OUTBOX_RELAY_INTERVAL_MS ?? "2000", 10), + batchSize: parseInt(process.env.OUTBOX_RELAY_BATCH_SIZE ?? "10", 10), + maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS ?? "8", 10), + leaseSeconds: parseInt(process.env.OUTBOX_LEASE_SECONDS ?? "120", 10), + }, + slashing: { + challengeWindowSeconds: parseInt(process.env.SLASH_CHALLENGE_WINDOW_SECONDS ?? "600", 10), + clockSkewToleranceSeconds: parseInt(process.env.SLASH_CLOCK_SKEW_TOLERANCE_SECONDS ?? "30", 10), + maxSubmitAttempts: parseInt(process.env.SLASH_MAX_SUBMIT_ATTEMPTS ?? "5", 10), + }, + + archival: { + enabled: process.env.ARCHIVAL_ENABLED === "true", + bucketName: process.env.ARCHIVAL_BUCKET_NAME ?? "vortex-archives", + endpoint: process.env.ARCHIVAL_S3_ENDPOINT ?? "", + region: process.env.ARCHIVAL_S3_REGION ?? "us-east-1", + accessKeyId: process.env.ARCHIVAL_S3_ACCESS_KEY_ID ?? "", + secretAccessKey: process.env.ARCHIVAL_S3_SECRET_ACCESS_KEY ?? "", + retentionDays: parseInt(process.env.ARCHIVAL_RETENTION_DAYS ?? "30", 10), + partitionPrefix: process.env.ARCHIVAL_PARTITION_PREFIX ?? "date=", + maxRowsPerFile: parseInt(process.env.ARCHIVAL_MAX_ROWS_PER_FILE ?? "100000", 10), + }, }); +/** + * Parse a JSON object of string values from an env var, falling back to an + * empty map for anything unparseable or non-object (keeps a typo in + * `EVM_RPC_URLS` from crashing boot; the verifier then reports per-chain + * "no RPC URL" failures instead). + */ +export function parseJsonMap(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + +/** + * Parse and validate reputation weights + knobs. + * + * In dev/test, weights are renormalised on the fly so deployers can tweak a + * single weight and get a working system. In production, env.validation.ts + * already fails the boot when the sum is off by more than 1e-9, so the + * renormalisation branch below is effectively a no-op. + */ +function buildReputationConfig(env: NodeJS.ProcessEnv): AppConfig["reputation"] { + const fillRate = Number(env.REP_WEIGHT_FILL_RATE ?? 0.35); + const latency = Number(env.REP_WEIGHT_LATENCY ?? 0.15); + const slashes = Number(env.REP_WEIGHT_SLASHES ?? 0.25); + const quoteHonour = Number(env.REP_WEIGHT_QUOTE_HONOUR ?? 0.15); + const volume = Number(env.REP_WEIGHT_VOLUME ?? 0.10); + const sum = fillRate + latency + slashes + quoteHonour + volume; + let w = { fillRate, latency, slashes, quoteHonour, volume }; + if (sum > 0 && Math.abs(sum - 1) > 1e-9) { + w = { + fillRate: fillRate / sum, + latency: latency / sum, + slashes: slashes / sum, + quoteHonour: quoteHonour / sum, + volume: volume / sum, + }; + } + return { + weights: w, + decayHalflifeSeconds: + clampPositiveInt(env.REP_DECAY_HALFLIFE_SECONDS, 30 * 24 * 60 * 60), + bayesAlpha: Math.max(0.5, Number(env.REP_BAYES_ALPHA ?? 4)), + bayesBeta: Math.max(0.5, Number(env.REP_BAYES_BETA ?? 1)), + volumeLambdaUsd: Math.max(1, Number(env.REP_VOLUME_LAMBDA_USD ?? 100000)), + historyWindowDays: (() => { + const raw = parseInt(env.REP_HISTORY_WINDOW_DAYS ?? "30", 10); + if (!Number.isFinite(raw) || raw < 1) return 30; + if (raw > 365) return 365; + return raw; + })(), + }; +} + /** Parse `SHADOW_SAMPLE_RATE` into a probability, defaulting to full sampling. */ function clampSampleRate(raw: string | undefined): number { if (raw === undefined || raw.trim() === "") return 1; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 7d4c88ec..7bc4ba5e 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,263 +1,3 @@ -import Joi from "joi"; - -/** - * Joi schema for environment variables. - * - * Rules: - * - Booleans accept "true"/"false" strings (env vars are always strings). - * - Production secrets are required when NODE_ENV=production. - * - New env vars for issues #411 (replica URLs), #412 (cursor secret), - * and #413 (archival) are added here. - */ - -export const envValidationSchema = Joi.object({ - // ── Core ─────────────────────────────────────────────────────────────────── - NODE_ENV: Joi.string().valid("development", "production", "test").default("development"), - PORT: Joi.number().integer().min(1).max(65535).default(4000), - CORS_ORIGIN: Joi.string().default("*"), - - // ── Database ─────────────────────────────────────────────────────────────── - DATABASE_URL: Joi.string().default("postgresql://vortex:vortex@localhost:5432/vortex?schema=public"), - - /** - * #411 — Read-replica URLs. - * Comma-separated list of Postgres connection strings for read replicas. - * Leave blank to use the primary for all reads. - */ - DATABASE_REPLICA_URLS: Joi.string().allow("").default(""), - MAX_REPLICA_LAG_MS: Joi.number().integer().min(100).max(60000).default(5000), - - // ── Stellar ──────────────────────────────────────────────────────────────── - STELLAR_NETWORK: Joi.string().valid("testnet", "futurenet", "mainnet").default("testnet"), - SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), - SOROBAN_RPC_URLS: Joi.string().allow("").default(""), - ARCHIVAL_RPC_URL: Joi.string().allow("").default(""), - HORIZON_URL: Joi.string().uri().default("https://horizon-testnet.stellar.org"), - STELLAR_SIGNER_SECRET_KEY: Joi.string().allow("").default(""), - SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), - SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), - TREASURY_ADDRESS: Joi.string().allow("").default(""), - - SOROBAN_SIGNING_KEY: Joi.when("NODE_ENV", { - is: "production", - then: Joi.string() - .pattern(/^S[A-Z2-7]{55}$/, "Stellar secret seed (S + 55 base32 chars)") - .required(), - otherwise: Joi.string() - .pattern(/^(S[A-Z2-7]{55})?$/, "Stellar secret seed or empty") - .allow("") - .default(""), - }), - - SIGNER_BACKEND: Joi.string().valid("local", "vault").default("local"), - VAULT_ADDR: Joi.string().allow("").default(""), - VAULT_TOKEN: Joi.string().allow("").default(""), - VAULT_TRANSIT_KEY_NAME: Joi.string().default("vortex-signer"), - ALLOW_LOCAL_SIGNER_IN_PROD: Joi.boolean().default(false), - - // ── On-chain writes ──────────────────────────────────────────────────────── - ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false), - ONCHAIN_DRY_RUN: Joi.when("NODE_ENV", { - is: "production", - then: Joi.boolean().required(), - otherwise: Joi.boolean().default(true), - }), - SOROBAN_FEE_PERCENTILE: Joi.string() - .valid("min", "mode", "p10", "p20", "p30", "p40", "p50", "p60", "p70", "p80", "p90", "p95", "p99", "max") - .default("p50"), - SOROBAN_MAX_FEE_STROOPS: Joi.number().integer().min(0).default(1_000_000), - CHANNEL_POOL_SIZE: Joi.number().integer().min(1).default(8), - CHANNEL_SECRET_KEYS: Joi.string().allow("").default(""), - - // ── Persistence ──────────────────────────────────────────────────────────── - INTENTS_STORE: Joi.string().valid("memory", "dual", "postgres").default("memory"), - INTENTS_PERSISTENCE: Joi.string().valid("memory", "dual", "postgres").default("memory"), - INTENTS_VERIFY_INTERVAL_MS: Joi.number().integer().min(0).default(60000), - SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - TOKENS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - - // ── Intent retention ─────────────────────────────────────────────────────── - INTENT_RETENTION_DAYS: Joi.number().integer().min(0).default(30), - INTENT_RETENTION_SWEEP_MS: Joi.number().integer().min(0).default(60000), - - // ── WebSocket ────────────────────────────────────────────────────────────── - WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000), - WS_BACKPLANE: Joi.string().valid("memory", "redis").default("memory"), - WS_MAX_PAYLOAD_BYTES: Joi.number().integer().min(1024).default(16384), - WS_MAX_CONNECTIONS_PER_IP: Joi.number().integer().min(0).default(20), - WS_TRUST_PROXY_HOPS: Joi.number().integer().min(0).default(0), - WS_RATE_LIMIT_PER_SEC: Joi.number().integer().min(0).default(10), - WS_RATE_LIMIT_BURST: Joi.number().integer().min(0).default(20), - WS_RATE_LIMIT_MAX_VIOLATIONS: Joi.number().integer().min(0).default(5), - WS_OUTBOUND_QUEUE_MAX: Joi.number().integer().min(0).default(1000), - WS_OUTBOUND_BUFFER_BYTES: Joi.number().integer().min(0).default(1048576), - WS_SLOW_CONSUMER_POLICY: Joi.string().valid("drop_oldest", "disconnect").default("drop_oldest"), - WS_DRAIN_TIMEOUT_MS: Joi.number().integer().min(0).default(25000), - - // ── SSE ──────────────────────────────────────────────────────────────────── - SSE_HEARTBEAT_MS: Joi.number().integer().min(0).default(15000), - SSE_MAX_BUFFER_BYTES: Joi.number().integer().min(0).default(1048576), - - // ── Redis ────────────────────────────────────────────────────────────────── - REDIS_URL: Joi.string().allow("").default("redis://localhost:6379"), - - // ── Jobs ─────────────────────────────────────────────────────────────────── - JOBS_DRIVER: Joi.string().valid("memory", "bullmq").default("memory"), - JOBS_SHUTDOWN_TIMEOUT_MS: Joi.number().integer().min(0).default(25000), - PROCESS_ROLE: Joi.string().valid("api", "worker", "all").default("all"), - - // ── Kill-switch ──────────────────────────────────────────────────────────── - KILLSWITCH_OPERATOR_TOKEN: Joi.when("NODE_ENV", { - is: "production", - then: Joi.string().min(1).required(), - otherwise: Joi.string().allow("").default(""), - }), - KILLSWITCH_REDIS_URL: Joi.string().allow("").default(""), - KILLSWITCH_POLL_MS: Joi.number().integer().min(100).max(5000).default(2000), - KILLSWITCH_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - - // ── Auth ─────────────────────────────────────────────────────────────────── - AUTH_JWT_SECRET: Joi.string().allow("").default(""), - ADMIN_API_KEYS: Joi.string().allow("").default(""), - - // ── EVM ──────────────────────────────────────────────────────────────────── - ETHEREUM_RPC_URL: Joi.string().allow("").default(""), - ETHEREUM_ESCROW_ADDRESS: Joi.string().allow("").default(""), - BASE_RPC_URL: Joi.string().allow("").default(""), - BASE_ESCROW_ADDRESS: Joi.string().allow("").default(""), - POLYGON_RPC_URL: Joi.string().allow("").default(""), - POLYGON_ESCROW_ADDRESS: Joi.string().allow("").default(""), - ARBITRUM_RPC_URL: Joi.string().allow("").default(""), - ARBITRUM_ESCROW_ADDRESS: Joi.string().allow("").default(""), - OPTIMISM_RPC_URL: Joi.string().allow("").default(""), - OPTIMISM_ESCROW_ADDRESS: Joi.string().allow("").default(""), - AVALANCHE_RPC_URL: Joi.string().allow("").default(""), - AVALANCHE_ESCROW_ADDRESS: Joi.string().allow("").default(""), - EVM_RPC_ALLOWLIST: Joi.string().allow("").default(""), - EVM_RPC_URLS: Joi.string().allow("").default("{}"), - EVM_ESCROW_ADDRESSES: Joi.string().allow("").default("{}"), - EVM_DEPOSIT_VERIFICATION_ENABLED: Joi.boolean().default(false), - EVM_TRANSFER_FEE_TOLERANCE_BPS: Joi.number().integer().min(0).default(0), - EVM_LOG_LOOKBACK_BLOCKS: Joi.number().integer().min(0).default(10000), - - // ── Resource limits ──────────────────────────────────────────────────────── - JSON_MAX_DEPTH: Joi.number().integer().min(1).max(100).default(10), - WS_MAX_FILTER_CHAINS: Joi.number().integer().min(1).default(20), - WS_MAX_SUBSCRIPTIONS: Joi.number().integer().min(1).default(10), - DB_QUERY_TIMEOUT_MS: Joi.number().integer().min(0).default(5000), - DB_BATCH_QUERY_TIMEOUT_MS: Joi.number().integer().min(0).default(10000), - DB_STATS_QUERY_TIMEOUT_MS: Joi.number().integer().min(0).default(15000), - - // ── Flags ────────────────────────────────────────────────────────────────── - FLAGS_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), - FLAGS_REFRESH_MS: Joi.number().integer().min(0).default(30000), - FLAG_OVERRIDES: Joi.string().allow("").default(""), - - // ── Shadow mode ──────────────────────────────────────────────────────────── - SHADOW_MODE_ENABLED: Joi.boolean().default(false), - SHADOW_SAMPLE_RATE: Joi.number().min(0).max(1).default(1), - SHADOW_QUEUE_MAX: Joi.number().integer().min(0).default(256), - SHADOW_CONCURRENCY: Joi.number().integer().min(1).default(4), - SHADOW_SOURCE_ACCOUNT: Joi.string().allow("").default(""), - - // ── Health ───────────────────────────────────────────────────────────────── - HEALTH_CHECK_INTERVAL_MS: Joi.number().integer().min(100).default(5000), - HEALTH_READY_FAILURE_THRESHOLD: Joi.number().integer().min(1).default(3), - HEALTH_READY_SUCCESS_THRESHOLD: Joi.number().integer().min(1).default(2), - HEALTH_EVENT_LOOP_MAX_LAG_MS: Joi.number().integer().min(100).default(1000), - SERVICE_ROLES: Joi.string().default("api,ws,worker"), - - // ── Governance ───────────────────────────────────────────────────────────── - PARAMS_CONTRACT_ID: Joi.string().allow("").default(""), - PARAMS_POLL_INTERVAL_MS: Joi.number().integer().min(0).default(30000), - - // ── Leader election ──────────────────────────────────────────────────────── - LEADER_ELECTION_ENABLED: Joi.boolean().default(false), - LEADER_ELECTION_HEARTBEAT_MS: Joi.number().integer().min(0).default(5000), - - // ── Observability ────────────────────────────────────────────────────────── - LOG_LEVEL: Joi.string().valid("error", "warn", "info", "http", "verbose", "debug", "silly").default("debug"), - LOG_SERVICE_NAME: Joi.string().default("vortex-backend"), - SENTRY_DSN: Joi.string().allow("").default(""), - METRICS_TOKEN: Joi.string().allow("").default(""), - LOG_SHIPPING_ENABLED: Joi.boolean().default(false), - LOG_SHIPPING_HOST: Joi.string().allow("").default(""), - LOG_SHIPPING_PORT: Joi.number().integer().min(1).max(65535).default(514), - LOG_SHIPPING_PATH: Joi.string().default("/"), - LOG_SHIPPING_SSL: Joi.boolean().default(false), - - // ── Reconciler ───────────────────────────────────────────────────────────── - RECONCILE_STALE_SECONDS: Joi.number().integer().min(0).default(300), - - // ── Misc ─────────────────────────────────────────────────────────────────── - CANARY_ADDRESSES: Joi.string().allow("").default(""), - ALLOW_LEGACY_STELLAR_SIGNATURES: Joi.boolean().default(false), - SAFETY_SWEEP_INTERVAL_MS: Joi.number().integer().min(0).default(300000), - RATE_LIMIT_LOCAL_PRUNE_MS: Joi.number().integer().min(0).default(60000), - RATE_LIMIT_REDIS_URL: Joi.string().allow("").default(""), - CREDENTIAL_REVOCATION_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), - GUARDIAN_CONTRACT_ID: Joi.string().allow("").default(""), - DATASETS_ENABLED: Joi.boolean().default(false), - DATASETS_ANONYMIZE: Joi.boolean().default(true), - DATASETS_SALT: Joi.string().allow("").default(""), - DATASETS_SALT_ROTATION_HOURS: Joi.number().integer().min(1).default(24), - DATASETS_SALT_RETENTION_WINDOWS: Joi.number().integer().min(1).default(2), - DATASETS_PUBLIC_BUCKET: Joi.string().allow("").default("vortex-public-datasets"), - DATASETS_STORAGE_KIND: Joi.string().valid("memory", "local").default("memory"), - DATASETS_LOCAL_DIR: Joi.string().allow("").default("./data/datasets"), - SECRETS_PROVIDER: Joi.string().valid("env", "aws-secrets-manager", "vault-kv").default("env"), - SECRETS_REFRESH_INTERVAL_MS: Joi.number().integer().min(0).default(60000), - SECRETS_EXTRA: Joi.string().allow("").default(""), - AWS_SECRETS_MANAGER_PREFIX: Joi.string().allow("").default(""), - AWS_SECRETS_MANAGER_POLL_INTERVAL_MS: Joi.number().integer().min(0).default(60000), - VAULT_KV_MOUNT: Joi.string().default("secret"), - VAULT_KV_PREFIX: Joi.string().default("vortex/"), - VAULT_KV_POLL_INTERVAL_MS: Joi.number().integer().min(0).default(60000), - JWT_SIGNING_KEY: Joi.string().allow("").default(""), - WEBHOOK_SECRET: Joi.string().allow("").default(""), - CHANNEL_KEY: Joi.string().allow("").default(""), - EGRESS_TIMEOUT_MS: Joi.number().integer().min(0).default(10000), - EGRESS_MAX_REDIRECTS: Joi.number().integer().min(0).default(3), - EGRESS_MAX_BODY_SIZE_BYTES: Joi.number().integer().min(0).default(10485760), - SOROBAN_RPC_ALLOWLIST: Joi.string().allow("").default("soroban-testnet.stellar.org,soroban-rpc.stellar.org"), - WEBHOOK_ALLOWLIST: Joi.string().allow("").default(""), - ORACLE_ALLOWLIST: Joi.string().allow("").default(""), - MAX_USER_SLIPPAGE_BPS: Joi.number().integer().min(0).default(100), - MAX_PREMIUM_BPS: Joi.number().integer().min(0).default(50), - ORACLE_FAIL_OPEN_MAX_USD: Joi.number().min(0).default(100), - ORACLE_MAX_STALENESS_MS: Joi.number().integer().min(0).default(60000), - OUTBOX_RELAY_ENABLED: Joi.boolean().default(true), - OUTBOX_RELAY_INTERVAL_MS: Joi.number().integer().min(0).default(2000), - OUTBOX_RELAY_BATCH_SIZE: Joi.number().integer().min(1).default(10), - OUTBOX_MAX_ATTEMPTS: Joi.number().integer().min(1).default(8), - OUTBOX_LEASE_SECONDS: Joi.number().integer().min(0).default(120), - SLASH_CHALLENGE_WINDOW_SECONDS: Joi.number().integer().min(0).default(600), - SLASH_CLOCK_SKEW_TOLERANCE_SECONDS: Joi.number().integer().min(0).default(30), - SLASH_MAX_SUBMIT_ATTEMPTS: Joi.number().integer().min(1).default(5), - SOLVER_SECRET: Joi.string().allow("").default(""), - SOLVER_ADDRESS: Joi.string().allow("").default(""), - SOLVER_CHAINS: Joi.string().default("stellar,ethereum,base,polygon,arbitrum,optimism,avalanche"), - - // ── #411 — Read replicas ─────────────────────────────────────────────────── - // DATABASE_REPLICA_URLS and MAX_REPLICA_LAG_MS already defined above. - - // ── #412 — Cursor HMAC secret ────────────────────────────────────────────── - CURSOR_HMAC_SECRET: Joi.string().allow("").default("dev-cursor-hmac-secret-do-not-use-in-prod"), - - // ── #413 — Cold-storage archival ─────────────────────────────────────────── - ARCHIVAL_ENABLED: Joi.boolean().default(false), - ARCHIVAL_BUCKET_NAME: Joi.string().default("vortex-archives"), - ARCHIVAL_S3_ENDPOINT: Joi.string().allow("").default(""), - ARCHIVAL_S3_REGION: Joi.string().default("us-east-1"), - ARCHIVAL_S3_ACCESS_KEY_ID: Joi.string().allow("").default(""), - ARCHIVAL_S3_SECRET_ACCESS_KEY: Joi.string().allow("").default(""), - ARCHIVAL_RETENTION_DAYS: Joi.number().integer().min(1).default(30), - ARCHIVAL_PARTITION_PREFIX: Joi.string().default("date="), - ARCHIVAL_MAX_ROWS_PER_FILE: Joi.number().integer().min(1000).default(100000), -}).options({ allowUnknown: true }); - -// Re-export for consumers that need the inferred type. -export type EnvConfig = ReturnType["value"]; import * as Joi from "joi"; // Stellar secret seeds ("S..." strkeys) are 56-char base32: prefix + 32-byte @@ -284,6 +24,13 @@ export const envValidationSchema = Joi.object({ .uri({ scheme: ["postgresql", "postgres"] }) .default("postgresql://vortex:vortex@localhost:5432/vortex?schema=public"), + // ── #411 — Read replicas ───────────────────────────────────────────────── + // Comma-separated Postgres connection strings for read replicas. + // Leave blank to route all reads to the primary. + DATABASE_REPLICA_URLS: Joi.string().allow("").default(""), + // Maximum replica replication lag (ms) before a replica is bypassed. + MAX_REPLICA_LAG_MS: Joi.number().integer().min(100).max(60000).default(5000), + STELLAR_NETWORK: Joi.string().valid("testnet", "futurenet", "mainnet").default("testnet"), SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), // Horizon base URL, used for account/balance reads (treasury, canary tooling). @@ -312,6 +59,21 @@ export const envValidationSchema = Joi.object({ ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false), // Stellar public key of the treasury account (fee/slash/refund accumulator). TREASURY_ADDRESS: Joi.string().allow("").default(""), + + ALLOW_LEGACY_STELLAR_SIGNATURES: Joi.boolean().default(false), + ETHEREUM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + ETHEREUM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + BASE_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + BASE_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + POLYGON_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + POLYGON_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + ARBITRUM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + ARBITRUM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + OPTIMISM_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + OPTIMISM_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + AVALANCHE_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + AVALANCHE_ESCROW_ADDRESS: Joi.string().pattern(/^$|^0x[a-fA-F0-9]{40}$/).default(""), + EVM_RPC_ALLOWLIST: Joi.string().allow("").default(""), CORS_ORIGIN: Joi.string().default("*"), WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000), SOROBAN_FEE_PERCENTILE: Joi.string() @@ -336,10 +98,11 @@ export const envValidationSchema = Joi.object({ WS_BACKPLANE: Joi.string().valid("memory", "redis").default("memory"), REDIS_URL: Joi.string().uri({ scheme: ["redis", "rediss"] }).default("redis://localhost:6379"), - // ── WebSocket replay store (issue #457) ────────────────────────────────────── + // ── WebSocket replay store (issue #457) ─────────────────────────────────── + // WS_REPLAY_MAX_AGE_MS stays optional-by-omission (commented in the + // .env examples) and is read directly where the buffer is built. WS_REPLAY_STORE: Joi.string().valid("memory", "redis").default("memory"), WS_REPLAY_MAX_COUNT: Joi.number().integer().min(1).default(500), - WS_REPLAY_MAX_AGE_MS: Joi.number().integer().min(1).optional(), // ── Persistence adapter selection ───────────────────────────────────────── // Controls which repository adapter is used for intents and solvers. @@ -367,6 +130,22 @@ export const envValidationSchema = Joi.object({ // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. SENTRY_DSN: Joi.string().uri().allow("").default(""), + // Bearer token that guards GET /metrics (issue #298). + // When set, Prometheus scrape jobs must supply: + // Authorization: Bearer + // When empty (the default): + // - non-production: unauthenticated scraping allowed (local dev Prometheus) + // - production: rejected at boot — #298 requires a non-empty token of at + // least 16 characters so a deploy can never expose /metrics without + // authentication (fail closed at validation time, not at scrape time). + // Generate with: openssl rand -hex 32 + METRICS_TOKEN: Joi.string() + .when("NODE_ENV", { + is: Joi.valid("production"), + then: Joi.string().required().invalid("").min(16), + otherwise: Joi.string().allow("").default(""), + }), + // Winston log level. Defaults to "debug" in dev/test and "info" in production. LOG_LEVEL: Joi.string() .valid("error", "warn", "info", "http", "verbose", "debug", "silly") @@ -420,6 +199,7 @@ export const envValidationSchema = Joi.object({ // Must be explicitly set to "true" — any other value is treated as false. // A startup warning is emitted when this is enabled in production. ALLOW_LOCAL_SIGNER_IN_PROD: Joi.boolean().default(false), + // ── Resource-exhaustion limits (issue #476) ─────────────────────────────── // These values are consumed by src/config/limits.config.ts at startup and // override the compile-time defaults when set. All have safe defaults so @@ -542,6 +322,7 @@ export const envValidationSchema = Joi.object({ // Optional: when empty the monitor reports `contract_unconfigured` rather // than silently recording zero divergence. SHADOW_SOURCE_ACCOUNT: Joi.string().allow("").default(""), + // ── Governance parameters contract ──────────────────────────────────────── // When set, ProtocolParamsService reads current + scheduled protocol // parameters (fee bps, fill windows, deadlines, exposure ratio, slash @@ -552,6 +333,7 @@ export const envValidationSchema = Joi.object({ // How often (ms) to poll the parameters contract. 30 s is the default; // lower values increase RPC load; raise in production if rate-limited. PARAMS_POLL_INTERVAL_MS: Joi.number().integer().min(5_000).default(30_000), + // ── Leader election (issue #493) ────────────────────────────────────────── // Controls whether Postgres advisory-lock based leader election is enabled // for singleton workers (sweeper, event-ingestion). @@ -597,6 +379,11 @@ export const envValidationSchema = Joi.object({ .pattern(/^([A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})(,[A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})*$/) .default(""), + // ── Public anonymised datasets ──────────────────────────────────────────── + // Legacy storage-backend selector (superseded by DATASETS_STORAGE below, + // still exported by the .env*.example files). + DATASETS_STORAGE_KIND: Joi.string().valid("local", "memory").default("memory"), + // ── Guardian emergency ingestion (issue #507) ───────────────────────────── GUARDIAN_CONTRACT_ID: Joi.string().allow("").default(""), @@ -635,4 +422,220 @@ export const envValidationSchema = Joi.object({ DATASETS_PUBLIC_BUCKET: Joi.string().default("vortex-public-datasets"), DATASETS_STORAGE: Joi.string().valid("local", "memory").default("local"), DATASETS_LOCAL_DIR: Joi.string().default(".datasets"), + + // ── #412 — Cursor HMAC secret ────────────────────────────────────────────── + // HMAC-SHA256 key used to sign opaque pagination cursors. + // Generate with: openssl rand -hex 32 + CURSOR_HMAC_SECRET: Joi.string().allow("").default("dev-cursor-hmac-secret-do-not-use-in-prod"), + + // ── #413 — Cold-storage archival ─────────────────────────────────────────── + ARCHIVAL_ENABLED: Joi.boolean().default(false), + ARCHIVAL_BUCKET_NAME: Joi.string().default("vortex-archives"), + ARCHIVAL_S3_ENDPOINT: Joi.string().allow("").default(""), + ARCHIVAL_S3_REGION: Joi.string().default("us-east-1"), + ARCHIVAL_S3_ACCESS_KEY_ID: Joi.string().allow("").default(""), + ARCHIVAL_S3_SECRET_ACCESS_KEY: Joi.string().allow("").default(""), + ARCHIVAL_RETENTION_DAYS: Joi.number().integer().min(1).default(30), + ARCHIVAL_PARTITION_PREFIX: Joi.string().default("date="), + ARCHIVAL_MAX_ROWS_PER_FILE: Joi.number().integer().min(1000).default(100000), + // ── Secrets Manager (issue #465) ──────────────────────────────────────────── + SECRETS_PROVIDER: Joi.string().valid("env", "aws-secrets-manager", "vault-kv").default("env"), + SECRETS_REFRESH_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + SECRETS_EXTRA: Joi.string().allow("").default(""), + + // AWS Secrets Manager + AWS_SECRETS_MANAGER_PREFIX: Joi.string().allow("").default(""), + AWS_SECRETS_MANAGER_POLL_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + + // Vault KV + VAULT_KV_MOUNT: Joi.string().default("secret"), + VAULT_KV_PREFIX: Joi.string().default("vortex/"), + VAULT_KV_POLL_INTERVAL_MS: Joi.number().integer().min(5000).default(60000), + + // Extra secret env vars referenced by the default SecretConfig + JWT_SIGNING_KEY: Joi.string().allow("").default(""), + WEBHOOK_SECRET: Joi.string().allow("").default(""), + CHANNEL_KEY: Joi.string().allow("").default(""), + // ── Egress / SSRF Protection (issue #468) ───────────────────────────────── + // Controls the centralized HttpEgressService used for all outbound HTTP requests + // (RPC, Horizon, oracles, webhooks) to prevent SSRF attacks. + EGRESS_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(10000), + EGRESS_MAX_REDIRECTS: Joi.number().integer().min(0).max(5).default(3), + EGRESS_MAX_BODY_SIZE_BYTES: Joi.number().integer().min(1024).default(10485760), // 10MB + SOROBAN_RPC_ALLOWLIST: Joi.string().allow("").default(""), + WEBHOOK_ALLOWLIST: Joi.string().allow("").default(""), + ORACLE_ALLOWLIST: Joi.string().allow("").default(""), + + // ── WS gateway hardening (issue #455) ───────────────────────────────────── + WS_MAX_PAYLOAD_BYTES: Joi.number().integer().min(1024).default(16384), + WS_MAX_CONNECTIONS_PER_IP: Joi.number().integer().min(0).default(20), + // Hops of trusted reverse proxies in front of the service. 0 ignores + // X-Forwarded-For entirely so clients cannot spoof their IP. + WS_TRUST_PROXY_HOPS: Joi.number().integer().min(0).default(0), + WS_RATE_LIMIT_PER_SEC: Joi.number().positive().default(10), + WS_RATE_LIMIT_BURST: Joi.number().integer().min(1).default(20), + WS_RATE_LIMIT_MAX_VIOLATIONS: Joi.number().integer().min(1).default(5), + WS_OUTBOUND_QUEUE_MAX: Joi.number().integer().min(1).default(1000), + WS_OUTBOUND_BUFFER_BYTES: Joi.number().integer().min(1024).default(1048576), + WS_SLOW_CONSUMER_POLICY: Joi.string().valid("drop_oldest", "disconnect").default("drop_oldest"), + // HS256 secret shared with the SEP-10 auth endpoint (#442). Empty disables + // JWT auth; signature auth keeps working. + AUTH_JWT_SECRET: Joi.string().allow("").min(32).default(""), + + // ── Distributed rate limiting (issue #441) ───────────────────────────────── + // How often the local rate-limiter fallback prunes expired window entries. + // Only relevant during a Redis outage; keeps the fallback map bounded. + RATE_LIMIT_LOCAL_PRUNE_MS: Joi.number().integer().min(1000).max(60000).default(60000), + + // Redis URL for the distributed rate limiter (#441). Leave empty to force the + // bounded local limiter (the limit is still enforced, per process). + RATE_LIMIT_REDIS_URL: Joi.string().allow("").optional(), + + // ── Scoped solver credentials (issue #443) ───────────────────────────────── + // Cross-replica transport for credential revocation invalidation. + CREDENTIAL_REVOCATION_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), + + // ── SSE intent feed (issue #433) ─────────────────────────────────────────── + // Heartbeat comment interval and per-client backpressure limit for the + // Server-Sent Events intent stream. + SSE_HEARTBEAT_MS: Joi.number().integer().min(1000).max(60000).default(15000), + SSE_MAX_BUFFER_BYTES: Joi.number().integer().min(1024).default(1048576), + + // ── Health probes (issue #492) ──────────────────────────────────────────── + // Comma-separated roles this process serves: api, ws, worker. + SERVICE_ROLES: Joi.string() + .pattern(/^(api|ws|worker)(,(api|ws|worker))*$/) + .default("api,ws,worker"), + HEALTH_CHECK_INTERVAL_MS: Joi.number().integer().min(500).default(5000), + HEALTH_READY_FAILURE_THRESHOLD: Joi.number().integer().min(1).default(3), + HEALTH_READY_SUCCESS_THRESHOLD: Joi.number().integer().min(1).default(2), + HEALTH_EVENT_LOOP_MAX_LAG_MS: Joi.number().integer().min(50).default(1000), + // Comma-separated Soroban RPC URLs for the RPC-quorum readiness check. + // Defaults to SOROBAN_RPC_URL. + SOROBAN_RPC_HEALTH_URLS: Joi.string().allow("").default(""), + + // ── Soroban RPC pool & archival (#509 #510) ─────────────────────────────── + // Comma-separated Soroban RPC endpoints; falls back to SOROBAN_RPC_URL. + SOROBAN_RPC_URLS: Joi.string().allow("").default(""), + // Optional archival RPC used for ledger-entry history reads. + ARCHIVAL_RPC_URL: Joi.string().uri({ scheme: ["http", "https"] }).allow("").default(""), + // Signing channel pool sizing for concurrent on-chain submissions. + CHANNEL_POOL_SIZE: Joi.number().integer().min(1).max(64).default(8), + // Comma-separated secret seeds, one per signing channel. + CHANNEL_SECRET_KEYS: Joi.string().allow("").default(""), + // Fee ceiling for on-chain submissions, in stroops. + SOROBAN_MAX_FEE_STROOPS: Joi.number().integer().min(0).default(1000000), + // Seconds after which a stale reconcile cursor triggers a rescan. + RECONCILE_STALE_SECONDS: Joi.number().integer().min(1).default(300), + + // ── Postgres intents store (#404 #405) ──────────────────────────────────── + // Backend for persisted intents: in-memory or Postgres-backed. + INTENTS_STORE: Joi.string().valid("memory", "postgres").default("memory"), + // Interval between background verification passes over the stored intents. + INTENTS_VERIFY_INTERVAL_MS: Joi.number().integer().min(1000).default(60000), + + // ── Oracle fair-value validation (#548) ─────────────────────────────────── + // Max quote slippage versus oracle fair value before a quote is rejected. + MAX_USER_SLIPPAGE_BPS: Joi.number().integer().min(0).default(100), + // Max premium over fair value a solver may quote before rejection. + MAX_PREMIUM_BPS: Joi.number().integer().min(0).default(50), + // Oracle staleness tolerance before prices are considered unusable. + ORACLE_MAX_STALENESS_MS: Joi.number().integer().min(1000).default(60000), + // Aggregate USD value for which a stale feed fails open instead of closed. + ORACLE_FAIL_OPEN_MAX_USD: Joi.number().min(0).default(100), + + // ── Transactional outbox (#396 #397) ────────────────────────────────────── + OUTBOX_RELAY_ENABLED: Joi.boolean().default(true), + OUTBOX_RELAY_INTERVAL_MS: Joi.number().integer().min(100).default(2000), + OUTBOX_RELAY_BATCH_SIZE: Joi.number().integer().min(1).default(10), + OUTBOX_MAX_ATTEMPTS: Joi.number().integer().min(1).default(8), + OUTBOX_LEASE_SECONDS: Joi.number().integer().min(1).default(120), + + // ── Durable slashing saga (#396 #397) ───────────────────────────────────── + SLASH_CHALLENGE_WINDOW_SECONDS: Joi.number().integer().min(0).default(600), + SLASH_CLOCK_SKEW_TOLERANCE_SECONDS: Joi.number().integer().min(0).default(30), + SLASH_MAX_SUBMIT_ATTEMPTS: Joi.number().integer().min(1).default(5), + + // ── Protocol fees (#551) ────────────────────────────────────────────────── + // JSON array of versioned fee rules applied when quoting swaps. + FEE_RULES_JSON: Joi.string().default("[]"), + // JSON array of referral-configured fee overrides. + FEE_REFERRALS_JSON: Joi.string().default("[]"), + + // ── EVM deposit verification (#402-#405) ────────────────────────────────── + EVM_DEPOSIT_VERIFICATION_ENABLED: Joi.boolean().default(false), + // JSON object mapping chain id to RPC URL (e.g. {"1":"https://..."}). + EVM_RPC_URLS: Joi.string().default("{}"), + // Comma-separated escrow contract addresses accepted for deposits. + EVM_ESCROW_ADDRESSES: Joi.string().allow("").default(""), + EVM_TRANSFER_FEE_TOLERANCE_BPS: Joi.number().integer().min(0).default(0), + EVM_LOG_LOOKBACK_BLOCKS: Joi.number().integer().min(0).default(10000), + + // ── Maintenance sweeps & WS shutdown (#547 #511) ────────────────────────── + // Interval for the background safety sweep over stuck intents. + SAFETY_SWEEP_INTERVAL_MS: Joi.number().integer().min(1000).default(300000), + // Grace period for draining open WebSocket connections on shutdown. + WS_DRAIN_TIMEOUT_MS: Joi.number().integer().min(0).default(25000), + + // ── Token persistence & price feed (issues #565 #566) ───────────────────── + // Repository backend for the token catalogue: "memory" (default, tests and + // single-process dev) or "prisma" (postgres-backed, restart-surviving). + TOKENS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), + // JSON object mapping token symbols to CoinGecko IDs, e.g. {"BTC":"bitcoin"}. + PRICE_FEED_COIN_IDS: Joi.string().default("{}"), + // Optional CoinGecko Pro key; empty falls back to the free-tier rate limits. + PRICE_FEED_API_KEY: Joi.string().allow("").default(""), + PRICE_FEED_REFRESH_INTERVAL_MS: Joi.number().integer().min(10_000).default(60_000), + // Single-tick move (%) above which the circuit breaker pauses refreshes. + PRICE_FEED_CIRCUIT_BREAKER_THRESHOLD_PERCENT: Joi.number().min(1).max(100).default(50), + + // ── RFQ quote auction (issue #570) ──────────────────────────────────────── + // Milliseconds solvers get to respond to a quote request before the + // auction window closes. + QUOTE_AUCTION_WINDOW_MS: Joi.number().integer().min(50).default(300), + + // ── Solver reputation (issue #444) ──────────────────────────────────────── + // Weights for each reputation sub-component. Must sum to 1 in production + // (fail closed if misconfigured); in dev/test the app renormalises and + // logs a warning so local experimentation doesn't prevent boot. + REP_WEIGHT_FILL_RATE: Joi.number().min(0).max(1).default(0.35), + REP_WEIGHT_LATENCY: Joi.number().min(0).max(1).default(0.15), + REP_WEIGHT_SLASHES: Joi.number().min(0).max(1).default(0.25), + REP_WEIGHT_QUOTE_HONOUR: Joi.number().min(0).max(1).default(0.15), + REP_WEIGHT_VOLUME: Joi.number().min(0).max(1).default(0.10), + // Shared exponential-decay half-life for all event weights. + // Defaults to 30 days so scores are dominated by the last ~1 month. + REP_DECAY_HALFLIFE_SECONDS: Joi.number() + .integer() + .min(86400) + .default(30 * 24 * 60 * 60), + // Beta-distribution priors for the fill-rate Bayesian cold-start prior. + // (α=4, β=1) gives a ~80% prior mean so new solvers rank above poor + // established performers rather than at the very bottom. + REP_BAYES_ALPHA: Joi.number().min(0.5).default(4), + REP_BAYES_BETA: Joi.number().min(0.5).default(1), + // Volume-component knee scale, in USD-equivalent notional. + // $100k default: moving from $10k → $100k of decayed volume accounts + // for ~0.5 of the normalised volume score. + REP_VOLUME_LAMBDA_USD: Joi.number().min(1).default(100000), + // How many trailing days of daily snapshots the /reputation endpoint + // returns. 1..365; default 30. + REP_HISTORY_WINDOW_DAYS: Joi.number().integer().min(1).max(365).default(30), +}).custom((value, helpers) => { + const wSum = + (value.REP_WEIGHT_FILL_RATE ?? 0) + + (value.REP_WEIGHT_LATENCY ?? 0) + + (value.REP_WEIGHT_SLASHES ?? 0) + + (value.REP_WEIGHT_QUOTE_HONOUR ?? 0) + + (value.REP_WEIGHT_VOLUME ?? 0); + // Require exact-within-tolerance only in production. Dev/test accept any + // weights and let configuration.ts renormalise them. + if (value.NODE_ENV === "production" && Math.abs(wSum - 1) > 1e-9) { + return helpers.message({ + custom: + `Reputation weights must sum to 1.0 in production, got ${wSum.toFixed(6)} ` + + "from REP_WEIGHT_{FILL_RATE,LATENCY,SLASHES,QUOTE_HONOUR,VOLUME}.", + }); + } + return value; }); diff --git a/src/generated/api-types.ts b/src/generated/api-types.ts index 124cdfca..43c18a41 100644 --- a/src/generated/api-types.ts +++ b/src/generated/api-types.ts @@ -12,14 +12,14 @@ // prettier-ignore export interface paths { - "/health": { + "/metrics": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["HealthController_check"]; + get: operations["MetricsController_index"]; put?: never; post?: never; delete?: never; @@ -28,14 +28,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/chain/health": { + "/ops/killswitch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getHealth"]; + /** Current kill-switch state and propagation health. */ + get: operations["KillSwitchController_status"]; put?: never; post?: never; delete?: never; @@ -44,62 +45,64 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/chain/ledger": { + "/ops/killswitch/pause": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getLatestLedger"]; + get?: never; put?: never; - post?: never; + /** Pause a scope. Effective on every replica within the propagation budget. */ + post: operations["KillSwitchController_pause"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/chain/network": { + "/ops/killswitch/resume/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getNetwork"]; + get?: never; put?: never; - post?: never; + /** Approve a resume. Takes effect once two distinct operators have approved. */ + post: operations["KillSwitchController_approveResume"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/chain/account/{publicKey}": { + "/intents": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SorobanController_getAccount"]; + get: operations["IntentsController_list"]; put?: never; - post?: never; + post: operations["IntentsController_create"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/tokens": { + "/intents/open": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["TokensController_getTokens"]; + get: operations["IntentsController_listOpen"]; put?: never; post?: never; delete?: never; @@ -108,14 +111,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/tokens/stellar": { + "/intents/user/{address}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["TokensController_getStellarTokens"]; + get: operations["IntentsController_listByUser"]; put?: never; post?: never; delete?: never; @@ -124,30 +127,34 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents": { + "/intents/{id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_list"]; + get: operations["IntentsController_getOne"]; put?: never; - post: operations["IntentsController_create"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/open": { + "/intents/{id}/audit": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_listOpen"]; + /** + * Get audit trail for an intent + * @description Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why. + */ + get: operations["IntentsController_getAudit"]; put?: never; post?: never; delete?: never; @@ -156,14 +163,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents/user/{address}": { + "/intents/{id}/quote": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_listByUser"]; + get: operations["IntentsController_getPersistedQuote"]; put?: never; post?: never; delete?: never; @@ -172,75 +179,79 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/intents/{id}": { + "/intents/batch": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getOne"]; + get?: never; put?: never; - post?: never; + /** + * Batch-fetch current intent records by ID + * @description Returns the current record for each supplied intent ID. IDs with no matching record are omitted (not individually 404'd). Capped at 100 IDs. + */ + post: operations["IntentsController_batchLookup"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/audit": { + "/intents/batch-create": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; + get?: never; + put?: never; /** - * Get audit trail for an intent - * @description Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why. + * Create multiple intents atomically + * @description Creates up to 50 intents in a single atomic (all-or-nothing) request. If any item fails validation or exceeds open intent limits, zero intents are created and per-item errors are reported. */ - get: operations["IntentsController_getAudit"]; - put?: never; - post?: never; + post: operations["IntentsController_batchCreate"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/quote": { + "/intents/{id}/accept": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getPersistedQuote"]; + get?: never; put?: never; - post?: never; + post: operations["IntentsController_accept"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/auction": { + "/intents/{id}/fill": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["IntentsController_getAuctionPrice"]; + get?: never; put?: never; - post?: never; + post: operations["IntentsController_fill"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/accept": { + "/intents/{id}/cancel": { parameters: { query?: never; header?: never; @@ -249,14 +260,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_accept"]; + post: operations["IntentsController_cancel"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/fill": { + "/intents/{id}/amend": { parameters: { query?: never; header?: never; @@ -265,14 +276,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_fill"]; + post: operations["IntentsController_amend"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/{id}/cancel": { + "/intents/quote": { parameters: { query?: never; header?: never; @@ -281,14 +292,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_cancel"]; + post: operations["IntentsController_quote"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/intents/quote": { + "/intents/{id}/requote": { parameters: { query?: never; header?: never; @@ -297,21 +308,42 @@ export interface paths { }; get?: never; put?: never; - post: operations["IntentsController_quote"]; + /** Re-quote an existing open intent using its stored fields */ + post: operations["IntentsController_requote"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/solvers": { + "/docs/ws": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["SolversController_getLeaderboard"]; + /** + * AsyncAPI specification for the Vortex WebSocket feed + * @description Returns the AsyncAPI 2.6 YAML document describing the vortex.v1 subprotocol. Use this with AsyncAPI Studio or SDK generators. + */ + get: operations["WsDocsController_getSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getLegacyLeaderboard"]; put?: never; post: operations["SolversController_register"]; delete?: never; @@ -320,7 +352,43 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}": { + "/solvers/leaderboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Windowed solver leaderboard + * @description Returns the ranked solver list for a specific window. This endpoint is intended for recent-performance visibility and does not alter the legacy all-time leaderboard. + */ + get: operations["SolversController_getLeaderboard"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/eligible-intents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getEligibleIntents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}": { parameters: { query?: never; header?: never; @@ -336,7 +404,7 @@ export interface paths { patch: operations["SolversController_updateSolver"]; trace?: never; }; - "/api/v1/solvers/{address}/stats": { + "/solvers/{address}/stats": { parameters: { query?: never; header?: never; @@ -352,7 +420,55 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/deregister": { + "/solvers/{address}/slashes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SolversController_getSlashHistory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/slashes/{slashId}/dispute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SolversController_submitDispute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/slashes/{slashId}/dispute/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SolversController_resolveDispute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/deregister": { parameters: { query?: never; header?: never; @@ -368,7 +484,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/deactivate": { + "/solvers/{address}/deactivate": { parameters: { query?: never; header?: never; @@ -384,7 +500,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/solvers/{address}/reactivate": { + "/solvers/{address}/reactivate": { parameters: { query?: never; header?: never; @@ -400,14 +516,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/stats": { + "/api/v1/admin/griefing": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["StatsController_getStats"]; + /** List solvers currently under anti-griefing enforcement */ + get: operations["SolverGriefingController_listEnforced"]; put?: never; post?: never; delete?: never; @@ -416,99 +533,658 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/stats/ws": { + "/api/v1/admin/griefing/{address}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["StatsController_getWsStats"]; + /** Get anti-griefing record for a solver */ + get: operations["SolverGriefingController_getSolverRecord"]; put?: never; post?: never; - delete?: never; + /** Reset a solver's anti-griefing state to ok */ + delete: operations["SolverGriefingController_resetSolver"]; options?: never; head?: never; patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { - StellarTokenDto: { - /** @description Stellar contract ID of the token */ - contract: string; - /** @example XLM */ - symbol: string; - /** @example Stellar Lumens */ - name: string; - /** @example 7 */ - decimals: number; - /** @example 0.1182 */ - priceUSD: number; + "/api/v1/admin/griefing/{address}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - StellarTokensResponseDto: { - tokens: components["schemas"]["StellarTokenDto"][]; + /** Get anti-griefing audit log for a solver */ + get: operations["SolverGriefingController_getSolverAudit"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/griefing/{address}/exclude/{intentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - CreateIntentDto: { - /** @description Stellar or EVM address of the user creating the intent */ - user: string; - /** - * @description Source chain the funds are coming from - * @enum {string} - */ - srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; - /** @description Source token contract/address on srcChain */ - srcTokenAddress: string; - /** @description Source token symbol, e.g. USDC */ - srcTokenSymbol: string; - /** @description Source token decimals */ - srcTokenDecimals: number; - /** @description Source amount as a non-negative integer string (base units) */ - srcAmount: string; - /** @description Destination Stellar token contract */ - dstTokenContract: string; - /** @description Destination token symbol, e.g. USDC */ - dstTokenSymbol: string; - /** @description Destination token decimals */ - dstTokenDecimals: number; - /** @description Minimum acceptable destination amount as an integer string */ - minDstAmount: string; - /** @description Optional Dutch-auction allocation terms */ - auction?: components["schemas"]["DutchAuctionDto"]; - /** @description Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h */ - deadline?: number; - /** @description EIP-712 signature for EVM-originated intent creation */ - signature?: string; - /** @description Unique nonce included in the EIP-712 signature */ - nonce?: string; - /** @description Unix timestamp when the EIP-712 signature expires */ - expiresAt?: number; - /** @description Idempotency key for deduplicating duplicate requests */ - idempotencyKey?: string; + get?: never; + put?: never; + /** Exclude an incident intent from griefing ratio */ + post: operations["SolverGriefingController_excludeIncident"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - DutchAuctionDto: { - /** @description Starting destination amount in base units */ - startDstAmount: string; - /** @description Unix timestamp when the price decay starts */ - decayStart: number; - /** @description Unix timestamp when the price reaches minDstAmount */ - decayEnd: number; - /** @description Solver address exclusively eligible until exclusivityEnd */ - exclusiveSolver?: string; - /** @description Unix timestamp when solver exclusivity ends */ - exclusivityEnd?: number; + get: operations["TokensController_getTokens"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tokens/stellar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - AcceptIntentDto: { - /** @description Solver address accepting the intent */ - solver: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64-encoded Ed25519 signature of the signed intent action */ - signature: string; + get: operations["TokensController_getStellarTokens"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/ledger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getLatestLedger"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/network": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getNetwork"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/chain/account/{publicKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SorobanController_getAccount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/shadow-report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Daily shadow-mode divergence summary + * @description Returns (expected, simulated) comparison counts for on-chain simulations run in parallel with the off-chain intent path, broken down by transition and by divergence reason, plus a per-UTC-day series and shadow queue health. Headline totals are lifetime-to-date; `days` bounds only the per-day breakdown. + */ + get: operations["ShadowController_shadowReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/params": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Current and pending governance-controlled protocol parameters + * @description Returns the actively-enforced protocol parameters, any pending governance change with its timelock execution ledger and ETA, and recent parameter history. Parameters are sourced from the on-chain governance contract when PARAMS_CONTRACT_ID is configured; code/env defaults are used otherwise. + */ + get: operations["ParamsController_getParams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/governance/guardian/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Active guardian actions (with tx references) and effective pause state */ + get: operations["GuardianController_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/governance/guardian/actions/{id}/override": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Superadmin override of an active guardian action (audited) */ + post: operations["GuardianController_override"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a solver's credentials + * @description Lists credential metadata (never plaintext) for the authenticated solver. + */ + get: operations["SolverCredentialController_list"]; + put?: never; + /** + * Create a scoped solver credential + * @description Mints a new scoped credential for the authenticated solver. The plaintext secret is returned ONCE. Requires a valid SEP-10 JWT for the solver. + */ + post: operations["SolverCredentialController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate a solver credential + * @description Revokes the credential and issues a replacement with the same scopes. The new plaintext is returned ONCE. + */ + post: operations["SolverCredentialController_rotate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/solvers/{address}/credentials/{id}/revoke": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke a solver credential + * @description Instantly revokes the credential and propagates to all replicas. + */ + post: operations["SolverCredentialController_revoke"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/jobs/queues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Depth and dead-letter counts per queue */ + get: operations["JobsController_queues"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/jobs/queues/{queue}/dead-letters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Most recent dead-lettered jobs for a queue */ + get: operations["JobsController_deadLetters"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Managed flags with env default, override pin and stored rules */ + get: operations["FlagsController_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Set a flag's default and targeting rules + * @description Returns 202-style { status: 'pending' } when a second approval is required. + */ + put: operations["FlagsController_update"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/change-requests/{id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve a pending change (must be a different admin than the proposer) */ + post: operations["FlagsController_approve"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/flags/{key}/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit trail for a flag, newest first */ + get: operations["FlagsController_auditLog"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_live"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_ready"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/startup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_startup"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_check"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats/treasury": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getTreasuryStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats/ws": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getWsStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get treasury reconciliation summary + * @description Returns daily reconciliation summary showing expected vs actual balances per asset + */ + get: operations["TreasuryController_getReconciliation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get detailed reconciliation for an asset + * @description Returns detailed reconciliation data including transaction breakdown + */ + get: operations["TreasuryController_getAssetReconciliation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/treasury/reconciliation/trigger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Manually trigger treasury reconciliation + * @description Admin endpoint to trigger reconciliation on-demand + */ + post: operations["TreasuryController_triggerReconciliation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + PauseKillSwitchDto: { + /** + * @description Breadth of the pause. + * @enum {string} + */ + scope: "global" | "chain" | "token" | "operation"; + /** @description Required for chain/token/operation scope. */ + chain?: string; + /** @description Required for token scope; wildcard for operation scope. */ + token?: string; + /** + * @description Required for operation scope. + * @enum {string} + */ + operation?: "create" | "accept" | "fill" | "slash" | "onchain"; + /** @enum {string} */ + reasonCode: "INCIDENT" | "TOKEN_DEPEGGED" | "SOLVER_INCIDENT" | "CHAIN_DEGRADED" | "RPC_DEGRADED" | "REGULATORY" | "MAINTENANCE"; + /** @description Operator explanation, shown to clients and in the audit log. */ + reason: string; + }; + ApproveResumeDto: { + /** @description Why the resume is safe. */ + note?: string; + /** + * @description Distinct approvals needed. + * @default 2 + */ + approvalsRequired: number; + }; + CreateIntentDto: { + /** @description Stellar address of the user creating the intent */ + user: string; + /** + * @description Source chain the funds are coming from + * @enum {string} + */ + srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Source token contract/address on srcChain */ + srcTokenAddress: string; + /** @description Source token symbol, e.g. USDC */ + srcTokenSymbol: string; + /** @description Source token decimals */ + srcTokenDecimals: number; + /** @description Source amount as a non-negative integer string (base units) */ + srcAmount: string; + /** @description Destination Stellar token contract */ + dstTokenContract: string; + /** @description Destination token symbol, e.g. USDC */ + dstTokenSymbol: string; + /** @description Destination token decimals */ + dstTokenDecimals: number; + /** @description Minimum acceptable destination amount as an integer string */ + minDstAmount: string; + /** @description Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h */ + deadline?: number; + /** @description Idempotency key for deduplicating duplicate requests */ + idempotencyKey?: string; + }; + BatchLookupDto: { + /** @description Intent IDs to look up (max 100). IDs with no matching record are omitted from the response, not individually 404'd. */ + intentIds: string[]; + }; + BatchCreateIntentsDto: { + /** @description List of intents to create atomically (1..50) */ + intents: components["schemas"]["CreateIntentDto"][]; + }; + BatchCreateItemErrorDto: { + /** @description Zero-based index of the failed intent item in the input array */ + index: number; + /** @description Field name associated with the error, if applicable */ + field?: string; + /** @description Human-readable error description */ + message: string; + }; + BatchCreateIntentsResponseDto: { + /** @description Array of created Intent objects when successful */ + created: string[]; + /** @description List of per-item validation errors if any failed */ + errors: components["schemas"]["BatchCreateItemErrorDto"][]; + }; + AcceptIntentDto: { + /** @description Solver address accepting the intent */ + solver: string; + /** @description Base64-encoded Ed25519 signature of the message "accept::" produced by the solver's private key */ + signature: string; }; FillIntentDto: { /** @description Solver address filling the intent (must match the accepting solver) */ @@ -517,155 +1193,1117 @@ export interface components { fillAmount: string; /** @description Stellar fill transaction hash */ txHash?: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64-encoded Ed25519 signature of the signed intent action */ + /** @description Base64-encoded Ed25519 signature of the message "fill::" produced by the solver's private key */ + signature: string; + }; + CancelIntentDto: { + /** @description Stellar address of the intent's original creator (must match) */ + user: string; + /** @description Base64-encoded Ed25519 signature of the message "cancel:" produced by the private key of `user` */ + signature: string; + }; + AmendIntentDto: { + /** @description Stellar address of the intent's original creator (must match) */ + user: string; + /** @description Replacement minimum destination amount in base units */ + minDstAmount: string; + /** @description Replacement Unix timestamp deadline */ + deadline: number; + /** @description Base64 Ed25519 signature of "amend::::" */ + signature: string; + }; + QuoteRequestDto: { + /** + * @description Source chain the funds are coming from + * @enum {string} + */ + srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Source token symbol, e.g. USDC */ + srcTokenSymbol: string; + /** @description Source amount as a non-negative integer string */ + srcAmount: string; + /** @description Destination token symbol, e.g. USDC */ + dstTokenSymbol: string; + /** @description Intent ID to persist the quote to */ + intentId?: string; + /** @description Source token contract address / ID (used for precise token resolution) */ + srcTokenAddress?: string; + /** @description Destination Stellar token contract ID (used for precise token resolution) */ + dstTokenContract?: string; + }; + RouteStepDto: { + /** @enum {string} */ + type: "bridge" | "swap" | "transfer"; + /** @description Protocol name, e.g. 'direct-solver', 'uniswap-v3' */ + protocol: string; + fromChain: string; + toChain: string; + /** @description Source token info for this hop */ + fromToken: Record; + /** @description Destination token info for this hop */ + toToken: Record; + /** @description Estimated execution time in seconds for this step */ + estimatedTime: number; + /** @description Estimated gas cost in the source token's base unit */ + estimatedGas: string; + }; + RouteDto: { + /** @description Ordered list of steps to execute the swap */ + steps: components["schemas"]["RouteStepDto"][]; + /** @description Total estimated time for all steps in seconds */ + totalTime: number; + /** @description Total fees in USD across all steps */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; + }; + QuoteDto: { + /** @description Solver address */ + solver: string; + /** @description Solver name */ + solverName: string; + /** @description Destination amount as a string */ + dstAmount: string; + /** @description Protocol fee as a string */ + fee: string; + /** @description Estimated fill time in seconds */ + fillTime: number; + /** @description Unix timestamp when quote expires */ + expiresAt: number; + /** @description Total fees in USD (protocol fee converted at token price) */ + totalFeesUSD: number; + /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ + priceImpact: number; + /** @description Computed execution route (direct single-step or multi-hop via USDC intermediate) */ + route: components["schemas"]["RouteDto"]; + }; + QuoteResponseDto: { + /** @description Array of quotes sorted by best dstAmount first */ + quotes: components["schemas"]["QuoteDto"][]; + /** @description Best quote or null if no solvers available */ + bestQuote: components["schemas"]["QuoteDto"] | null; + /** @description Source chain */ + srcChain: string; + /** @description Source token symbol */ + srcTokenSymbol: string; + /** @description Source amount as a string */ + srcAmount: string; + /** @description Destination token symbol */ + dstTokenSymbol: string; + /** @description Estimated fill time in seconds for the best quote */ + estimatedFillTime: number; + /** @description Total fees in USD for the best quote (0 when no quote available) */ + totalFeesUSD: number; + /** @description Price impact for the best quote as a decimal fraction (0 when no quote available) */ + priceImpact: number; + /** @description True when no solver responded and the returned quote is indicative */ + indicative?: boolean; + }; + RegisterSolverDto: { + /** @description Solver's Stellar address */ + address: string; + /** @description Solver's display name */ + name: string; + /** @description Bond amount as a non-negative integer string (in USDC base units) */ + bondAmount: string; + /** @description Average fill time in seconds */ + avgFillTime: number; + /** @description Chains this solver supports */ + supportedChains: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; + /** @description Token symbols this solver supports */ + supportedTokens: unknown[][]; + /** @description Proof-of-control signature for the advertised solver address */ + proofSignature: string; + }; + UpdateSolverDto: { + /** @description New display name */ + name?: string; + /** @description Replacement list of chains this solver supports */ + supportedChains?: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; + /** @description Replacement list of supported token symbols */ + supportedTokens?: unknown[][]; + /** @description Updated average fill time in seconds */ + avgFillTime?: number; + /** @description Base64-encoded Ed25519 signature of the message "update-solver:
" produced by the solver's private key, proving control of :address */ signature: string; }; - CancelIntentDto: { - /** @description Stellar or EVM address of the intent's original creator (must match) */ - user: string; - /** @description Single-use signing nonce */ - nonce?: string; - /** @description Unix timestamp when the signature expires */ - expiresAt?: number; - /** @description Base64 Ed25519 or EIP-712 signature proving control of user */ - signature: string; + UpdateSolverStatusDto: { + /** @description Proof-of-control signature for the solver status update */ + signature: string; + }; + StellarTokenDto: { + /** @description Stellar contract ID of the token */ + contract: string; + /** @example XLM */ + symbol: string; + /** @example Stellar Lumens */ + name: string; + /** @example 7 */ + decimals: number; + /** @example 0.1182 */ + priceUSD: number; + }; + StellarTokensResponseDto: { + tokens: components["schemas"]["StellarTokenDto"][]; + }; + GuardianOverrideDto: Record; + CreateSolverCredentialDto: { + /** + * @description Scopes granted to the credential + * @enum {string} + */ + scopes: "solver:read" | "intents:read" | "quote:respond" | "intents:accept" | "intents:fill"; + /** @description Optional source-address allowlist. Entries are an exact IPv4 address, an IPv4 CIDR block, an exact IPv6 address, or `*` for any source. IPv6 CIDR blocks and hostnames are not supported; an entry that does not parse never matches, so a typo locks the credential out rather than opening it up. */ + ipAllowlist?: string[]; + /** @description Unix epoch seconds at which the credential expires (null = never) */ + expiresAt?: number; + }; + UpdateFlagDto: Record; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + MetricsController_index: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_pause: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PauseKillSwitchDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + KillSwitchController_approveResume: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ApproveResumeDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_list: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid limit or offset */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateIntentDto"]; + }; + }; + responses: { + /** @description Invalid request body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Open-intent cap reached — a single user may not hold more than 50 open/accepted intents simultaneously */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally) */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_listOpen: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_listByUser: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getOne: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getAudit: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Audit trail for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + intentId?: string; + entries?: { + /** Format: date-time */ + timestamp?: string; + toState?: string; + actor?: string; + reason?: string; + metadata?: Record | null; + }[]; + }; + }; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_getPersistedQuote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Persisted quote for the intent */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found or no quote persisted */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_batchLookup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchLookupDto"]; + }; + }; + responses: { + /** @description Records for the found intent IDs, plus a count */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description intentIds missing, not an array of strings, or exceeds 100 entries */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_batchCreate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchCreateIntentsDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BatchCreateIntentsResponseDto"]; + }; + }; + /** @description Invalid request payload or empty batch */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Per-item validation errors or limits exceeded */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_accept: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptIntentDto"]; + }; + }; + responses: { + /** @description Solver not registered or inactive */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent has expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_fill: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FillIntentDto"]; + }; + }; + responses: { + /** @description Fill amount below minimum */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Wrong solver for this intent */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in accepted state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Fill window has expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description An emergency kill-switch is active for this intent's scope (503 + Retry-After) */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_cancel: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CancelIntentDto"]; + }; + }; + responses: { + /** @description Unauthorized */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_amend: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AmendIntentDto"]; + }; + }; + responses: { + /** @description Unauthorized */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not amendable */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_quote: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QuoteRequestDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuoteResponseDto"]; + }; + }; + /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IntentsController_requote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QuoteResponseDto"]; + }; + }; + /** @description Intent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Intent is not in the open state */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + WsDocsController_getSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getLegacyLeaderboard: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterSolverDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getLeaderboard: { + parameters: { + query?: { + window?: "24h" | "7d" | "30d" | "all"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getEligibleIntents: { + parameters: { + query?: { + /** @description Filter by intent state */ + state?: "open" | "accepted" | "filled" | "cancelled" | "expired" | "slashed" | "pending_open" | "pending_accepted" | "pending_filled" | "pending_cancelled"; + /** @description Filter by user address */ + user?: string; + /** @description Filter by source chain */ + chain?: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; + /** @description Number of results per page (max 100) */ + limit?: number; + /** @description Cursor for the next page of intents */ + cursor?: string; + /** @description Number of results to skip */ + offset?: number; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_getSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_updateSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverDto"]; + }; }; - QuoteRequestDto: { - /** - * @description Source chain the funds are coming from - * @enum {string} - */ - srcChain: "stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche"; - /** @description Source token symbol, e.g. USDC */ - srcTokenSymbol: string; - /** @description Source amount as a non-negative integer string */ - srcAmount: string; - /** @description Destination token symbol, e.g. USDC */ - dstTokenSymbol: string; - /** @description Intent ID to persist the quote to */ - intentId?: string; - /** @description Source token contract address / ID (used for precise token resolution) */ - srcTokenAddress?: string; - /** @description Destination Stellar token contract ID (used for precise token resolution) */ - dstTokenContract?: string; + responses: { + /** @description Updated solver record */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid update body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Solver not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - RouteStepDto: { - /** @enum {string} */ - type: "bridge" | "swap" | "transfer"; - /** @description Protocol name, e.g. 'direct-solver', 'uniswap-v3' */ - protocol: string; - fromChain: string; - toChain: string; - /** @description Source token info for this hop */ - fromToken: Record; - /** @description Destination token info for this hop */ - toToken: Record; - /** @description Estimated execution time in seconds for this step */ - estimatedTime: number; - /** @description Estimated gas cost in the source token's base unit */ - estimatedGas: string; + }; + SolversController_getSolverStats: { + parameters: { + query: { + window: string; + }; + header?: never; + path: { + address: string; + }; + cookie?: never; }; - RouteDto: { - /** @description Ordered list of steps to execute the swap */ - steps: components["schemas"]["RouteStepDto"][]; - /** @description Total estimated time for all steps in seconds */ - totalTime: number; - /** @description Total fees in USD across all steps */ - totalFeesUSD: number; - /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ - priceImpact: number; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - QuoteDto: { - /** @description Solver address */ - solver: string; - /** @description Solver name */ - solverName: string; - /** @description Destination amount as a string */ - dstAmount: string; - /** @description Protocol fee as a string */ - fee: string; - /** @description Estimated fill time in seconds */ - fillTime: number; - /** @description Unix timestamp when quote expires */ - expiresAt: number; - /** @description Total fees in USD (protocol fee converted at token price) */ - totalFeesUSD: number; - /** @description Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3% */ - priceImpact: number; - /** @description Computed execution route (direct single-step or multi-hop via USDC intermediate) */ - route: components["schemas"]["RouteDto"]; + }; + SolversController_getSlashHistory: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; }; - QuoteResponseDto: { - /** @description Array of quotes sorted by best dstAmount first */ - quotes: components["schemas"]["QuoteDto"][]; - /** @description Best quote or null if no solvers available */ - bestQuote: components["schemas"]["QuoteDto"] | null; - /** @description Source chain */ - srcChain: string; - /** @description Source token symbol */ - srcTokenSymbol: string; - /** @description Source amount as a string */ - srcAmount: string; - /** @description Destination token symbol */ - dstTokenSymbol: string; - /** @description Estimated fill time in seconds for the best quote */ - estimatedFillTime: number; - /** @description Total fees in USD for the best quote (0 when no quote available) */ - totalFeesUSD: number; - /** @description Price impact for the best quote as a decimal fraction (0 when no quote available) */ - priceImpact: number; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - RegisterSolverDto: { - /** @description Solver's Stellar address */ - address: string; - /** @description Solver's display name */ - name: string; - /** @description Bond amount as a non-negative integer string (in USDC base units) */ - bondAmount: string; - /** @description Average fill time in seconds */ - avgFillTime: number; - /** @description Chains this solver supports */ - supportedChains: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; - /** @description Token symbols this solver supports */ - supportedTokens: unknown[][]; - /** @description Proof-of-control signature for the advertised solver address */ - proofSignature: string; + }; + SolversController_submitDispute: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + slashId: string; + }; + cookie?: never; }; - UpdateSolverDto: { - /** @description New display name */ - name?: string; - /** @description Replacement list of chains this solver supports */ - supportedChains?: ("stellar" | "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche")[]; - /** @description Replacement list of supported token symbols */ - supportedTokens?: unknown[][]; - /** @description Updated average fill time in seconds */ - avgFillTime?: number; - /** @description Base64-encoded Ed25519 signature of the message "update-solver:
" produced by the solver's private key, proving control of :address */ - signature: string; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - HealthController_check: { + SolversController_resolveDispute: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + slashId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_deregisterSolver: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_deactivate: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SolversController_reactivate: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSolverStatusDto"]; + }; + }; responses: { - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -673,7 +2311,7 @@ export interface operations { }; }; }; - SorobanController_getHealth: { + SolverGriefingController_listEnforced: { parameters: { query?: never; header?: never; @@ -682,111 +2320,84 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Soroban RPC node health status (pass-through of the RPC `getHealth` result). */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - /** @example healthy */ - status: string; - latestLedger?: number; - oldestLedger?: number; - ledgerRetentionWindow?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getLatestLedger: { + SolverGriefingController_getSolverRecord: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Latest closed ledger as reported by the Soroban RPC node. */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - id: string; - /** @example 12345678 */ - sequence: number; - protocolVersion?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getNetwork: { + SolverGriefingController_resetSolver: { parameters: { query?: never; header?: never; - path?: never; + path: { + address: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Network passphrase and protocol metadata for the configured RPC node. */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - friendbotUrl?: string | null; - /** @example Test SDF Network ; September 2015 */ - passphrase: string; - protocolVersion?: number; - }; - }; + content?: never; }; }; }; - SorobanController_getAccount: { + SolverGriefingController_getSolverAudit: { parameters: { query?: never; header?: never; path: { - /** @description Stellar Ed25519 account public key (starts with `G`, 56 characters). */ - publicKey: string; + address: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description On-chain account record (id, sequence number, and balances). */ 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - id: string; - /** @example 987654321 */ - sequence: string; - balances?: { - balance?: string; - asset_type?: string; - }[]; - }; - }; - }; - /** @description Invalid Stellar public key format */ - 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Per-account rate limit exceeded (AccountRateLimitGuard) */ - 429: { + }; + }; + SolverGriefingController_excludeIncident: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + intentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { headers: { [name: string]: unknown; }; @@ -877,67 +2488,58 @@ export interface operations { }; }; }; - IntentsController_list: { + SorobanController_getHealth: { parameters: { - query: { - /** @description Filter by intent state */ - state?: string; - /** @description Filter by user address */ - user?: string; - /** @description Filter by source chain */ - chain?: string; - /** @description Number of results per page */ - limit: number; - /** @description Cursor for the next page of intents */ - cursor?: string; - /** @description Number of results to skip */ - offset: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Invalid limit or offset */ - 400: { + /** @description Soroban RPC node health status (pass-through of the RPC `getHealth` result). */ + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example healthy */ + status: string; + latestLedger?: number; + oldestLedger?: number; + ledgerRetentionWindow?: number; + }; + }; }; }; }; - IntentsController_create: { + SorobanController_getLatestLedger: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateIntentDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Invalid request body */ - 400: { + /** @description Latest closed ledger as reported by the Soroban RPC node. */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally) */ - 429: { - headers: { - [name: string]: unknown; + content: { + "application/json": { + id: string; + /** @example 12345678 */ + sequence: number; + protocolVersion?: number; + }; }; - content?: never; }; }; }; - IntentsController_listOpen: { + SorobanController_getNetwork: { parameters: { query?: never; header?: never; @@ -946,46 +2548,60 @@ export interface operations { }; requestBody?: never; responses: { + /** @description Network passphrase and protocol metadata for the configured RPC node. */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + friendbotUrl?: string | null; + /** @example Test SDF Network ; September 2015 */ + passphrase: string; + protocolVersion?: number; + }; + }; }; }; }; - IntentsController_listByUser: { + SorobanController_getAccount: { parameters: { query?: never; header?: never; path: { - address: string; + /** @description Stellar Ed25519 account public key (starts with `G`, 56 characters). */ + publicKey: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description On-chain account record (id, sequence number, and balances). */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + id: string; + /** @example 987654321 */ + sequence: string; + balances?: { + balance?: string; + asset_type?: string; + }[]; + }; + }; }; - }; - }; - IntentsController_getOne: { - parameters: { - query?: never; - header?: never; - path: { - id: string; + /** @description Invalid Stellar public key format */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Intent not found */ - 404: { + /** @description Per-account rate limit exceeded (AccountRateLimitGuard) */ + 429: { headers: { [name: string]: unknown; }; @@ -993,99 +2609,161 @@ export interface operations { }; }; }; - IntentsController_getAudit: { + ShadowController_shadowReport: { parameters: { - query?: never; - header?: never; - path: { - id: string; + query?: { + /** @description Trailing UTC days of per-day breakdown to include (1-90, default 1). */ + days?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Audit trail for the intent */ + /** @description Shadow-mode divergence report */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - intentId?: string; - entries?: { - /** Format: date-time */ - timestamp?: string; - toState?: string; - actor?: string; - reason?: string; - metadata?: Record | null; + enabled?: boolean; + sampleRate?: number; + /** @example 2026-09-30 */ + day?: string; + /** Format: date-time */ + generatedAt?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + transitions?: { + /** @example accept */ + transition?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + }[]; + divergences?: { + /** @example fill */ + transition?: string; + /** + * @example outcome_mismatch + * @enum {string} + */ + reason?: "outcome_mismatch" | "simulation_error" | "simulation_exception" | "contract_unconfigured"; + count?: number; }[]; + daily?: { + /** @example 2026-09-30 */ + day?: string; + compared?: number; + diverged?: number; + divergenceRate?: number; + cells?: Record[]; + }[]; + queue?: { + depth?: number; + capacity?: number; + dropped?: number; + sampledOut?: number; + disabled?: number; + completed?: number; + }; }; }; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; - IntentsController_getPersistedQuote: { + ParamsController_getParams: { parameters: { query?: never; header?: never; - path: { - id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Persisted quote for the intent */ + /** @description Protocol parameters — current, pending, and history */ 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Intent not found or no quote persisted */ - 404: { - headers: { - [name: string]: unknown; + content: { + "application/json": { + /** @description Currently active protocol parameters */ + current: { + /** @example 3 */ + version: number; + /** + * @description Protocol fee in basis points + * @example 30 + */ + feeBps: number; + /** @description Per-chain deadline and fill-window overrides */ + chains: { + [key: string]: { + /** @example 900 */ + deadlineSeconds?: number; + /** @example 120 */ + fillWindowSeconds?: number; + }; + }; + /** + * @description Maximum on-chain exposure ratio (0–1) + * @example 0.05 + */ + maxExposureRatio: number; + /** @example 100000000 */ + slashAmount: string; + /** @example 12345678 */ + activeSinceLedger: number; + /** Format: date-time */ + adoptedAt: string; + }; + /** @description Scheduled governance change not yet activated, or null */ + pending: ({ + params?: Record; + /** @example 12349999 */ + executionLedger?: number; + /** + * Format: date-time + * @description Best-effort ETA for timelock execution + */ + estimatedEta?: string; + /** Format: date-time */ + observedAt?: string; + } | null) | null; + /** @description Previous parameter versions, newest-first (max 50) */ + history: Record[]; + }; }; - content?: never; }; }; }; - IntentsController_getAuctionPrice: { + GuardianController_status: { parameters: { query?: never; header?: never; - path: { id: string }; + path?: never; cookie?: never; }; requestBody?: never; responses: { 200: { - headers: { [name: string]: unknown }; - content: { - "application/json": { - intentId: string; - currentDstAmount: string; - acceptedDstAmount?: string; - timestamp: number; - }; + headers: { + [name: string]: unknown; }; + content?: never; }; - 404: { headers: { [name: string]: unknown }; content?: never }; }; }; - IntentsController_accept: { + GuardianController_override: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { id: string; }; @@ -1093,33 +2771,38 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["AcceptIntentDto"]; + "application/json": components["schemas"]["GuardianOverrideDto"]; }; }; responses: { - /** @description Solver not registered or inactive */ - 403: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; + }; + }; + SolverCredentialController_list: { + parameters: { + query?: never; + header?: never; + path: { + address: string; }; - /** @description Intent is not in open state */ - 409: { + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Credential metadata list */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent has expired */ - 410: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1127,51 +2810,62 @@ export interface operations { }; }; }; - IntentsController_fill: { + SolverCredentialController_create: { parameters: { query?: never; header?: never; path: { - id: string; + address: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["FillIntentDto"]; + "application/json": components["schemas"]["CreateSolverCredentialDto"]; }; }; responses: { - /** @description Fill amount below minimum */ - 400: { + /** @description Credential created (plaintext shown once) */ + 201: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Wrong solver for this intent */ - 403: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; + }; + }; + SolverCredentialController_rotate: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + id: string; }; - /** @description Intent is not in accepted state */ - 409: { + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSolverCredentialDto"]; + }; + }; + responses: { + /** @description Credential rotated (new plaintext shown once) */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Fill window has expired */ - 410: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1179,37 +2873,27 @@ export interface operations { }; }; }; - IntentsController_cancel: { + SolverCredentialController_revoke: { parameters: { query?: never; header?: never; path: { + address: string; id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CancelIntentDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Unauthorized */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Intent not found */ - 404: { + /** @description Credential revoked */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Intent is not in open state */ - 409: { + /** @description Missing or invalid SEP-10 JWT */ + 401: { headers: { [name: string]: unknown; }; @@ -1217,29 +2901,39 @@ export interface operations { }; }; }; - IntentsController_quote: { + JobsController_queues: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["QuoteRequestDto"]; - }; - }; + requestBody?: never; responses: { 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["QuoteResponseDto"]; - }; + content?: never; }; - /** @description Rate limit exceeded — max 20 quote requests per 60 s per IP */ - 429: { + }; + }; + JobsController_deadLetters: { + parameters: { + query?: never; + header: { + "x-admin-key": string; + }; + path: { + queue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; @@ -1247,10 +2941,12 @@ export interface operations { }; }; }; - SolversController_getLeaderboard: { + FlagsController_list: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path?: never; cookie?: never; }; @@ -1264,20 +2960,24 @@ export interface operations { }; }; }; - SolversController_register: { + FlagsController_update: { parameters: { query?: never; - header?: never; - path?: never; + header: { + "x-admin-key": string; + }; + path: { + key: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["RegisterSolverDto"]; + "application/json": components["schemas"]["UpdateFlagDto"]; }; }; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1285,12 +2985,14 @@ export interface operations { }; }; }; - SolversController_getSolver: { + FlagsController_approve: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { - address: string; + id: string; }; cookie?: never; }; @@ -1304,44 +3006,71 @@ export interface operations { }; }; }; - SolversController_updateSolver: { + FlagsController_auditLog: { parameters: { query?: never; - header?: never; + header: { + "x-admin-key": string; + }; path: { - address: string; + key: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateSolverDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Updated solver record */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Invalid update body */ - 400: { + }; + }; + HealthController_live: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Missing or invalid signature */ - 401: { + }; + }; + HealthController_ready: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Solver not found */ - 404: { + }; + }; + HealthController_startup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { headers: { [name: string]: unknown; }; @@ -1349,13 +3078,11 @@ export interface operations { }; }; }; - SolversController_getSolverStats: { + HealthController_check: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -1368,18 +3095,16 @@ export interface operations { }; }; }; - SolversController_deregisterSolver: { + StatsController_getStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1387,18 +3112,16 @@ export interface operations { }; }; }; - SolversController_deactivate: { + StatsController_getTreasuryStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1406,18 +3129,16 @@ export interface operations { }; }; }; - SolversController_reactivate: { + StatsController_getWsStats: { parameters: { query?: never; header?: never; - path: { - address: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1425,37 +3146,143 @@ export interface operations { }; }; }; - StatsController_getStats: { + TreasuryController_getReconciliation: { parameters: { - query?: never; + query?: { + /** @description Date in YYYY-MM-DD format (defaults to today) */ + date?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { + /** @description Reconciliation summary */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example 2026-09-28 */ + date?: string; + assets?: { + /** @example native */ + asset?: string; + /** @example 1000000000 */ + expectedBalance?: string; + /** @example 1000500000 */ + actualBalance?: string; + /** @example 500000 */ + discrepancy?: string; + /** @example 0.05 */ + discrepancyPercentage?: number; + /** @example false */ + hasUnexplainedDiscrepancy?: boolean; + explanation?: string | null; + }[]; + /** @example 3 */ + totalDiscrepancies?: number; + /** @example 1 */ + assetsWithUnexplainedDiscrepancies?: number; + /** @example 2026-09-28T00:00:00.000Z */ + lastReconciliationAt?: string; + }; + }; }; }; }; - StatsController_getWsStats: { + TreasuryController_getAssetReconciliation: { parameters: { - query?: never; + query?: { + /** @description Date in YYYY-MM-DD format (defaults to today) */ + date?: string; + }; + header?: never; + path: { + /** @description Asset identifier (e.g., 'native', 'USDC:ISSUER', or contract address) */ + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Detailed reconciliation data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example 2026-09-28 */ + snapshotDate?: string; + /** @example native */ + asset?: string; + /** @example 1000000000 */ + expectedBalance?: string; + /** @example 1000500000 */ + actualBalance?: string; + /** @example 500000 */ + discrepancy?: string; + /** @example 0.05 */ + discrepancyPercentage?: number; + /** @example 10000000 */ + toleranceThreshold?: string; + /** @example false */ + hasUnexplainedDiscrepancy?: boolean; + explanation?: string | null; + breakdown?: { + /** @example 500000000 */ + fees?: string; + /** @example 100000000 */ + slashes?: string; + /** @example 50000000 */ + refunds?: string; + }; + recentTransactions?: { + /** @enum {string} */ + type?: "fee" | "slash" | "refund"; + /** @example 1000000 */ + amount?: string; + /** @example 2026-09-28T12:00:00.000Z */ + timestamp?: string; + /** @example intent-uuid */ + reference?: string; + }[]; + }; + }; + }; + }; + }; + TreasuryController_triggerReconciliation: { + parameters: { + query?: { + /** @description Specific asset to reconcile (reconciles all if omitted) */ + asset?: string; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { + /** @description Reconciliation completed */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @example Reconciliation completed */ + message?: string; + results?: { + asset?: string; + hasUnexplainedDiscrepancy?: boolean; + discrepancy?: string; + }[]; + }; + }; }; }; }; diff --git a/src/generated/openapi.json b/src/generated/openapi.json index d966887b..5bf3a2bb 100644 --- a/src/generated/openapi.json +++ b/src/generated/openapi.json @@ -1,21 +1,1683 @@ { "openapi": "3.0.0", "paths": { - "/health": { + "/metrics": { + "get": { + "operationId": "MetricsController_index", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "metrics" + ] + } + }, + "/ops/killswitch": { + "get": { + "operationId": "KillSwitchController_status", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Current kill-switch state and propagation health.", + "tags": [ + "ops/killswitch" + ] + } + }, + "/ops/killswitch/pause": { + "post": { + "operationId": "KillSwitchController_pause", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseKillSwitchDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "summary": "Pause a scope. Effective on every replica within the propagation budget.", + "tags": [ + "ops/killswitch" + ] + } + }, + "/ops/killswitch/resume/{id}": { + "post": { + "operationId": "KillSwitchController_approveResume", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveResumeDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "summary": "Approve a resume. Takes effect once two distinct operators have approved.", + "tags": [ + "ops/killswitch" + ] + } + }, + "/intents": { + "get": { + "operationId": "IntentsController_list", + "parameters": [ + { + "name": "state", + "required": false, + "in": "query", + "description": "Filter by intent state", + "schema": { + "type": "string", + "enum": [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled" + ] + } + }, + { + "name": "user", + "required": false, + "in": "query", + "description": "Filter by user address", + "schema": { + "type": "string" + } + }, + { + "name": "chain", + "required": false, + "in": "query", + "description": "Filter by source chain", + "schema": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results per page (max 100)", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "Number of results to skip", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "400": { + "description": "Invalid limit or offset" + } + }, + "tags": [ + "intents" + ] + }, + "post": { + "operationId": "IntentsController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIntentDto" + } + } + } + }, + "responses": { + "400": { + "description": "Invalid request body" + }, + "409": { + "description": "Open-intent cap reached — a single user may not hold more than 50 open/accepted intents simultaneously" + }, + "429": { + "description": "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/open": { + "get": { + "operationId": "IntentsController_listOpen", + "parameters": [ + { + "name": "state", + "required": false, + "in": "query", + "description": "Filter by intent state", + "schema": { + "type": "string", + "enum": [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled" + ] + } + }, + { + "name": "user", + "required": false, + "in": "query", + "description": "Filter by user address", + "schema": { + "type": "string" + } + }, + { + "name": "chain", + "required": false, + "in": "query", + "description": "Filter by source chain", + "schema": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results per page (max 100)", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "Number of results to skip", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/user/{address}": { + "get": { + "operationId": "IntentsController_listByUser", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "description": "Filter by intent state", + "schema": { + "type": "string", + "enum": [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled" + ] + } + }, + { + "name": "user", + "required": false, + "in": "query", + "description": "Filter by user address", + "schema": { + "type": "string" + } + }, + { + "name": "chain", + "required": false, + "in": "query", + "description": "Filter by source chain", + "schema": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results per page (max 100)", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "Number of results to skip", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}": { + "get": { + "operationId": "IntentsController_getOne", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "Intent not found" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/audit": { + "get": { + "description": "Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why.", + "operationId": "IntentsController_getAudit", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "description": "Filter by intent state", + "schema": { + "type": "string", + "enum": [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled" + ] + } + }, + { + "name": "user", + "required": false, + "in": "query", + "description": "Filter by user address", + "schema": { + "type": "string" + } + }, + { + "name": "chain", + "required": false, + "in": "query", + "description": "Filter by source chain", + "schema": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results per page (max 100)", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "Number of results to skip", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Audit trail for the intent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "intentId": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "toState": { + "type": "string" + }, + "actor": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "metadata": { + "type": "object", + "nullable": true + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Intent not found" + } + }, + "summary": "Get audit trail for an intent", + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/quote": { + "get": { + "operationId": "IntentsController_getPersistedQuote", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Persisted quote for the intent" + }, + "404": { + "description": "Intent not found or no quote persisted" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/batch": { + "post": { + "description": "Returns the current record for each supplied intent ID. IDs with no matching record are omitted (not individually 404'd). Capped at 100 IDs.", + "operationId": "IntentsController_batchLookup", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchLookupDto" + } + } + } + }, + "responses": { + "200": { + "description": "Records for the found intent IDs, plus a count" + }, + "400": { + "description": "intentIds missing, not an array of strings, or exceeds 100 entries" + } + }, + "summary": "Batch-fetch current intent records by ID", + "tags": [ + "intents" + ] + } + }, + "/intents/batch-create": { + "post": { + "description": "Creates up to 50 intents in a single atomic (all-or-nothing) request. If any item fails validation or exceeds open intent limits, zero intents are created and per-item errors are reported.", + "operationId": "IntentsController_batchCreate", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreateIntentsDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreateIntentsResponseDto" + } + } + } + }, + "400": { + "description": "Invalid request payload or empty batch" + }, + "422": { + "description": "Per-item validation errors or limits exceeded" + } + }, + "summary": "Create multiple intents atomically", + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/accept": { + "post": { + "operationId": "IntentsController_accept", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcceptIntentDto" + } + } + } + }, + "responses": { + "403": { + "description": "Solver not registered or inactive" + }, + "404": { + "description": "Intent not found" + }, + "409": { + "description": "Intent is not in open state" + }, + "410": { + "description": "Intent has expired" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/fill": { + "post": { + "operationId": "IntentsController_fill", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FillIntentDto" + } + } + } + }, + "responses": { + "400": { + "description": "Fill amount below minimum" + }, + "403": { + "description": "Wrong solver for this intent" + }, + "404": { + "description": "Intent not found" + }, + "409": { + "description": "Intent is not in accepted state" + }, + "410": { + "description": "Fill window has expired" + }, + "503": { + "description": "An emergency kill-switch is active for this intent's scope (503 + Retry-After)" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/cancel": { + "post": { + "operationId": "IntentsController_cancel", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelIntentDto" + } + } + } + }, + "responses": { + "403": { + "description": "Unauthorized" + }, + "404": { + "description": "Intent not found" + }, + "409": { + "description": "Intent is not in open state" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/amend": { + "post": { + "operationId": "IntentsController_amend", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmendIntentDto" + } + } + } + }, + "responses": { + "403": { + "description": "Unauthorized" + }, + "404": { + "description": "Intent not found" + }, + "409": { + "description": "Intent is not amendable" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/quote": { + "post": { + "operationId": "IntentsController_quote", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuoteRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuoteResponseDto" + } + } + } + }, + "429": { + "description": "Rate limit exceeded — max 20 quote requests per 60 s per IP" + } + }, + "tags": [ + "intents" + ] + } + }, + "/intents/{id}/requote": { + "post": { + "operationId": "IntentsController_requote", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuoteResponseDto" + } + } + } + }, + "404": { + "description": "Intent not found" + }, + "409": { + "description": "Intent is not in the open state" + }, + "429": { + "description": "Rate limit exceeded — max 20 quote requests per 60 s per IP" + } + }, + "summary": "Re-quote an existing open intent using its stored fields", + "tags": [ + "intents" + ] + } + }, + "/docs/ws": { + "get": { + "description": "Returns the AsyncAPI 2.6 YAML document describing the vortex.v1 subprotocol. Use this with AsyncAPI Studio or SDK generators.", + "operationId": "WsDocsController_getSpec", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "summary": "AsyncAPI specification for the Vortex WebSocket feed", + "tags": [ + "docs" + ] + } + }, + "/solvers": { + "post": { + "operationId": "SolversController_register", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterSolverDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + }, + "get": { + "operationId": "SolversController_getLegacyLeaderboard", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/leaderboard": { + "get": { + "description": "Returns the ranked solver list for a specific window. This endpoint is intended for recent-performance visibility and does not alter the legacy all-time leaderboard.", + "operationId": "SolversController_getLeaderboard", + "parameters": [ + { + "name": "window", + "required": false, + "in": "query", + "schema": { + "enum": [ + "24h", + "7d", + "30d", + "all" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Windowed solver leaderboard", + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/eligible-intents": { + "get": { + "operationId": "SolversController_getEligibleIntents", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "description": "Filter by intent state", + "schema": { + "type": "string", + "enum": [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled" + ] + } + }, + { + "name": "user", + "required": false, + "in": "query", + "description": "Filter by user address", + "schema": { + "type": "string" + } + }, + { + "name": "chain", + "required": false, + "in": "query", + "description": "Filter by source chain", + "schema": { + "type": "string", + "enum": [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche" + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Number of results per page (max 100)", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + }, + { + "name": "cursor", + "required": false, + "in": "query", + "description": "Cursor for the next page of intents", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "description": "Number of results to skip", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}": { + "get": { + "operationId": "SolversController_getSolver", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + }, + "patch": { + "operationId": "SolversController_updateSolver", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSolverDto" + } + } + } + }, + "responses": { + "200": { + "description": "Updated solver record" + }, + "400": { + "description": "Invalid update body" + }, + "401": { + "description": "Missing or invalid signature" + }, + "404": { + "description": "Solver not found" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/stats": { + "get": { + "operationId": "SolversController_getSolverStats", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "window", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/slashes": { + "get": { + "operationId": "SolversController_getSlashHistory", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/slashes/{slashId}/dispute": { + "post": { + "operationId": "SolversController_submitDispute", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "slashId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/slashes/{slashId}/dispute/resolve": { + "post": { + "operationId": "SolversController_resolveDispute", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "slashId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/deregister": { + "post": { + "operationId": "SolversController_deregisterSolver", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSolverStatusDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/deactivate": { + "post": { + "operationId": "SolversController_deactivate", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSolverStatusDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/solvers/{address}/reactivate": { + "post": { + "operationId": "SolversController_reactivate", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSolverStatusDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "solvers" + ] + } + }, + "/api/v1/admin/griefing": { + "get": { + "operationId": "SolverGriefingController_listEnforced", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "summary": "List solvers currently under anti-griefing enforcement", + "tags": [ + "admin/griefing" + ] + } + }, + "/api/v1/admin/griefing/{address}": { + "get": { + "operationId": "SolverGriefingController_getSolverRecord", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Get anti-griefing record for a solver", + "tags": [ + "admin/griefing" + ] + }, + "delete": { + "operationId": "SolverGriefingController_resetSolver", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Reset a solver's anti-griefing state to ok", + "tags": [ + "admin/griefing" + ] + } + }, + "/api/v1/admin/griefing/{address}/audit": { + "get": { + "operationId": "SolverGriefingController_getSolverAudit", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Get anti-griefing audit log for a solver", + "tags": [ + "admin/griefing" + ] + } + }, + "/api/v1/admin/griefing/{address}/exclude/{intentId}": { + "post": { + "operationId": "SolverGriefingController_excludeIncident", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "intentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "summary": "Exclude an incident intent from griefing ratio", + "tags": [ + "admin/griefing" + ] + } + }, + "/tokens": { "get": { - "operationId": "HealthController_check", + "operationId": "TokensController_getTokens", + "parameters": [ + { + "name": "chain", + "required": false, + "in": "query", + "description": "Restrict the result to a single chain (e.g. `stellar`, `ethereum`, `base`). When omitted, every supported chain plus the Stellar token list is returned.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Supported tokens. With `chain` set, `{ tokens: Token[], chain }`; without it, `tokens` is keyed by chain and `stellarTokens` holds the Stellar list.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "contract": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "USDC" + }, + "name": { + "type": "string", + "example": "USD Coin" + }, + "decimals": { + "type": "number", + "example": 6 + }, + "priceUSD": { + "type": "number", + "example": 1 + } + }, + "required": [ + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + }, + "chain": { + "type": "string", + "example": "ethereum" + } + }, + "required": [ + "tokens", + "chain" + ] + }, + { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "USDC" + }, + "name": { + "type": "string", + "example": "USD Coin" + }, + "decimals": { + "type": "number", + "example": 6 + }, + "priceUSD": { + "type": "number", + "example": 1 + } + }, + "required": [ + "address", + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + } + }, + "stellarTokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contract": { + "type": "string" + }, + "symbol": { + "type": "string", + "example": "XLM" + }, + "name": { + "type": "string", + "example": "Stellar Lumens" + }, + "decimals": { + "type": "number", + "example": 7 + }, + "priceUSD": { + "type": "number", + "example": 0.1182 + } + }, + "required": [ + "contract", + "symbol", + "name", + "decimals", + "priceUSD" + ] + } + } + }, + "required": [ + "tokens", + "stellarTokens" + ] + } + ] + } + } + } + } + }, + "tags": [ + "tokens" + ] + } + }, + "/tokens/stellar": { + "get": { + "operationId": "TokensController_getStellarTokens", "parameters": [], "responses": { "200": { - "description": "" + "description": "The full list of supported Stellar destination tokens.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StellarTokensResponseDto" + } + } + } } }, "tags": [ - "health" + "tokens" ] } }, - "/api/v1/chain/health": { + "/chain/health": { "get": { "operationId": "SorobanController_getHealth", "parameters": [], @@ -54,7 +1716,7 @@ ] } }, - "/api/v1/chain/ledger": { + "/chain/ledger": { "get": { "operationId": "SorobanController_getLatestLedger", "parameters": [], @@ -91,7 +1753,7 @@ ] } }, - "/api/v1/chain/network": { + "/chain/network": { "get": { "operationId": "SorobanController_getNetwork", "parameters": [], @@ -128,7 +1790,7 @@ ] } }, - "/api/v1/chain/account/{publicKey}": { + "/chain/account/{publicKey}": { "get": { "operationId": "SorobanController_getAccount", "parameters": [ @@ -158,339 +1820,445 @@ "type": "string", "example": "987654321" }, - "balances": { + "balances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "balance": { + "type": "string" + }, + "asset_type": { + "type": "string" + } + } + } + } + }, + "required": [ + "id", + "sequence" + ] + } + } + } + }, + "400": { + "description": "Invalid Stellar public key format" + }, + "429": { + "description": "Per-account rate limit exceeded (AccountRateLimitGuard)" + } + }, + "tags": [ + "chain" + ] + } + }, + "/admin/shadow-report": { + "get": { + "description": "Returns (expected, simulated) comparison counts for on-chain simulations run in parallel with the off-chain intent path, broken down by transition and by divergence reason, plus a per-UTC-day series and shadow queue health. Headline totals are lifetime-to-date; `days` bounds only the per-day breakdown.", + "operationId": "ShadowController_shadowReport", + "parameters": [ + { + "name": "days", + "required": false, + "in": "query", + "description": "Trailing UTC days of per-day breakdown to include (1-90, default 1).", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Shadow-mode divergence report", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sampleRate": { + "type": "number" + }, + "day": { + "type": "string", + "example": "2026-09-30" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "compared": { + "type": "number" + }, + "diverged": { + "type": "number" + }, + "divergenceRate": { + "type": "number" + }, + "transitions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transition": { + "type": "string", + "example": "accept" + }, + "compared": { + "type": "number" + }, + "diverged": { + "type": "number" + }, + "divergenceRate": { + "type": "number" + } + } + } + }, + "divergences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "transition": { + "type": "string", + "example": "fill" + }, + "reason": { + "type": "string", + "example": "outcome_mismatch", + "enum": [ + "outcome_mismatch", + "simulation_error", + "simulation_exception", + "contract_unconfigured" + ] + }, + "count": { + "type": "number" + } + } + } + }, + "daily": { "type": "array", "items": { "type": "object", "properties": { - "balance": { - "type": "string" + "day": { + "type": "string", + "example": "2026-09-30" }, - "asset_type": { - "type": "string" + "compared": { + "type": "number" + }, + "diverged": { + "type": "number" + }, + "divergenceRate": { + "type": "number" + }, + "cells": { + "type": "array", + "items": { + "type": "object" + } } } } + }, + "queue": { + "type": "object", + "properties": { + "depth": { + "type": "number" + }, + "capacity": { + "type": "number" + }, + "dropped": { + "type": "number" + }, + "sampledOut": { + "type": "number" + }, + "disabled": { + "type": "number" + }, + "completed": { + "type": "number" + } + } } - }, - "required": [ - "id", - "sequence" - ] + } } } } - }, - "400": { - "description": "Invalid Stellar public key format" - }, - "429": { - "description": "Per-account rate limit exceeded (AccountRateLimitGuard)" } }, + "summary": "Daily shadow-mode divergence summary", "tags": [ - "chain" + "admin" ] } }, - "/api/v1/tokens": { + "/params": { "get": { - "operationId": "TokensController_getTokens", - "parameters": [ - { - "name": "chain", - "required": false, - "in": "query", - "description": "Restrict the result to a single chain (e.g. `stellar`, `ethereum`, `base`). When omitted, every supported chain plus the Stellar token list is returned.", - "schema": { - "type": "string" - } - } - ], + "description": "Returns the actively-enforced protocol parameters, any pending governance change with its timelock execution ledger and ETA, and recent parameter history. Parameters are sourced from the on-chain governance contract when PARAMS_CONTRACT_ID is configured; code/env defaults are used otherwise.", + "operationId": "ParamsController_getParams", + "parameters": [], "responses": { "200": { - "description": "Supported tokens. With `chain` set, `{ tokens: Token[], chain }`; without it, `tokens` is keyed by chain and `stellarTokens` holds the Stellar list.", + "description": "Protocol parameters — current, pending, and history", "content": { "application/json": { "schema": { - "oneOf": [ - { + "type": "object", + "properties": { + "current": { "type": "object", + "description": "Currently active protocol parameters", "properties": { - "tokens": { - "type": "array", - "items": { + "version": { + "type": "number", + "example": 3 + }, + "feeBps": { + "type": "number", + "example": 30, + "description": "Protocol fee in basis points" + }, + "chains": { + "type": "object", + "description": "Per-chain deadline and fill-window overrides", + "additionalProperties": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "contract": { - "type": "string" - }, - "symbol": { - "type": "string", - "example": "USDC" - }, - "name": { - "type": "string", - "example": "USD Coin" - }, - "decimals": { + "deadlineSeconds": { "type": "number", - "example": 6 + "example": 900 }, - "priceUSD": { + "fillWindowSeconds": { "type": "number", - "example": 1 + "example": 120 } - }, - "required": [ - "symbol", - "name", - "decimals", - "priceUSD" - ] + } } }, - "chain": { + "maxExposureRatio": { + "type": "number", + "example": 0.05, + "description": "Maximum on-chain exposure ratio (0–1)" + }, + "slashAmount": { "type": "string", - "example": "ethereum" + "example": "100000000" + }, + "activeSinceLedger": { + "type": "number", + "example": 12345678 + }, + "adoptedAt": { + "type": "string", + "format": "date-time" } }, "required": [ - "tokens", - "chain" + "version", + "feeBps", + "chains", + "maxExposureRatio", + "slashAmount", + "activeSinceLedger", + "adoptedAt" ] }, - { - "type": "object", - "properties": { - "tokens": { + "pending": { + "nullable": true, + "description": "Scheduled governance change not yet activated, or null", + "oneOf": [ + { "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "symbol": { - "type": "string", - "example": "USDC" - }, - "name": { - "type": "string", - "example": "USD Coin" - }, - "decimals": { - "type": "number", - "example": 6 - }, - "priceUSD": { - "type": "number", - "example": 1 - } - }, - "required": [ - "address", - "symbol", - "name", - "decimals", - "priceUSD" - ] + "properties": { + "params": { + "type": "object" + }, + "executionLedger": { + "type": "number", + "example": 12349999 + }, + "estimatedEta": { + "type": "string", + "format": "date-time", + "description": "Best-effort ETA for timelock execution" + }, + "observedAt": { + "type": "string", + "format": "date-time" } } }, - "stellarTokens": { - "type": "array", - "items": { - "type": "object", - "properties": { - "contract": { - "type": "string" - }, - "symbol": { - "type": "string", - "example": "XLM" - }, - "name": { - "type": "string", - "example": "Stellar Lumens" - }, - "decimals": { - "type": "number", - "example": 7 - }, - "priceUSD": { - "type": "number", - "example": 0.1182 - } - }, - "required": [ - "contract", - "symbol", - "name", - "decimals", - "priceUSD" - ] - } + { + "type": "null" } - }, - "required": [ - "tokens", - "stellarTokens" ] + }, + "history": { + "type": "array", + "description": "Previous parameter versions, newest-first (max 50)", + "items": { + "type": "object" + } } + }, + "required": [ + "current", + "pending", + "history" ] } } } } }, + "summary": "Current and pending governance-controlled protocol parameters", "tags": [ - "tokens" + "governance" ] } }, - "/api/v1/tokens/stellar": { + "/governance/guardian/status": { "get": { - "operationId": "TokensController_getStellarTokens", + "operationId": "GuardianController_status", "parameters": [], "responses": { "200": { - "description": "The full list of supported Stellar destination tokens.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StellarTokensResponseDto" - } - } - } + "description": "" } }, + "summary": "Active guardian actions (with tx references) and effective pause state", "tags": [ - "tokens" + "governance" ] } }, - "/api/v1/intents": { - "get": { - "operationId": "IntentsController_list", + "/governance/guardian/actions/{id}/override": { + "post": { + "operationId": "GuardianController_override", "parameters": [ { - "name": "state", - "required": false, - "in": "query", - "description": "Filter by intent state", - "schema": { - "type": "string" - } - }, - { - "name": "user", - "required": false, - "in": "query", - "description": "Filter by user address", - "schema": { - "type": "string" - } - }, - { - "name": "chain", - "required": false, - "in": "query", - "description": "Filter by source chain", + "name": "id", + "required": true, + "in": "path", "schema": { "type": "string" } }, { - "name": "limit", + "name": "x-admin-key", + "in": "header", "required": true, - "in": "query", - "description": "Number of results per page", - "schema": { - "minimum": 1, - "maximum": 100, - "default": 20, - "type": "number" - } - }, - { - "name": "cursor", - "required": false, - "in": "query", - "description": "Cursor for the next page of intents", "schema": { "type": "string" } - }, - { - "name": "offset", - "required": true, - "in": "query", - "description": "Number of results to skip", - "schema": { - "minimum": 0, - "default": 0, - "type": "number" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GuardianOverrideDto" + } } } - ], + }, "responses": { - "400": { - "description": "Invalid limit or offset" + "200": { + "description": "" } }, + "summary": "Superadmin override of an active guardian action (audited)", "tags": [ - "intents" + "governance" ] - }, + } + }, + "/solvers/{address}/credentials": { "post": { - "operationId": "IntentsController_create", - "parameters": [], + "description": "Mints a new scoped credential for the authenticated solver. The plaintext secret is returned ONCE. Requires a valid SEP-10 JWT for the solver.", + "operationId": "SolverCredentialController_create", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateIntentDto" + "$ref": "#/components/schemas/CreateSolverCredentialDto" } } } }, "responses": { - "400": { - "description": "Invalid request body" + "201": { + "description": "Credential created (plaintext shown once)" }, - "429": { - "description": "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)" + "401": { + "description": "Missing or invalid SEP-10 JWT" } }, + "summary": "Create a scoped solver credential", "tags": [ - "intents" + "solvers" ] - } - }, - "/api/v1/intents/open": { + }, "get": { - "operationId": "IntentsController_listOpen", - "parameters": [], + "description": "Lists credential metadata (never plaintext) for the authenticated solver.", + "operationId": "SolverCredentialController_list", + "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { - "description": "" + "description": "Credential metadata list" + }, + "401": { + "description": "Missing or invalid SEP-10 JWT" } }, + "summary": "List a solver's credentials", "tags": [ - "intents" + "solvers" ] } }, - "/api/v1/intents/user/{address}": { - "get": { - "operationId": "IntentsController_listByUser", + "/solvers/{address}/credentials/{id}/rotate": { + "post": { + "description": "Revokes the credential and issues a replacement with the same scopes. The new plaintext is returned ONCE.", + "operationId": "SolverCredentialController_rotate", "parameters": [ { "name": "address", @@ -499,22 +2267,53 @@ "schema": { "type": "string" } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSolverCredentialDto" + } + } + } + }, "responses": { "200": { - "description": "" + "description": "Credential rotated (new plaintext shown once)" + }, + "401": { + "description": "Missing or invalid SEP-10 JWT" } }, + "summary": "Rotate a solver credential", "tags": [ - "intents" + "solvers" ] } }, - "/api/v1/intents/{id}": { - "get": { - "operationId": "IntentsController_getOne", + "/solvers/{address}/credentials/{id}/revoke": { + "post": { + "description": "Instantly revokes the credential and propagates to all replicas.", + "operationId": "SolverCredentialController_revoke", "parameters": [ + { + "name": "address", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, { "name": "id", "required": true, @@ -525,24 +2324,27 @@ } ], "responses": { - "404": { - "description": "Intent not found" + "200": { + "description": "Credential revoked" + }, + "401": { + "description": "Missing or invalid SEP-10 JWT" } }, + "summary": "Revoke a solver credential", "tags": [ - "intents" + "solvers" ] } }, - "/api/v1/intents/{id}/audit": { + "/admin/jobs/queues": { "get": { - "description": "Returns the full state-transition history for an intent ordered oldest-first. Each entry records the state the intent moved into, who triggered it, and why.", - "operationId": "IntentsController_getAudit", + "operationId": "JobsController_queues", "parameters": [ { - "name": "id", + "name": "x-admin-key", + "in": "header", "required": true, - "in": "path", "schema": { "type": "string" } @@ -550,61 +2352,29 @@ ], "responses": { "200": { - "description": "Audit trail for the intent", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "intentId": { - "type": "string" - }, - "entries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "timestamp": { - "type": "string", - "format": "date-time" - }, - "toState": { - "type": "string" - }, - "actor": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "metadata": { - "type": "object", - "nullable": true - } - } - } - } - } - } - } - } - }, - "404": { - "description": "Intent not found" + "description": "" } }, - "summary": "Get audit trail for an intent", + "summary": "Depth and dead-letter counts per queue", "tags": [ - "intents" + "admin" ] } }, - "/api/v1/intents/{id}/quote": { + "/admin/jobs/queues/{queue}/dead-letters": { "get": { - "operationId": "IntentsController_getPersistedQuote", + "operationId": "JobsController_deadLetters", "parameters": [ { - "name": "id", + "name": "x-admin-key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "queue", "required": true, "in": "path", "schema": { @@ -614,56 +2384,54 @@ ], "responses": { "200": { - "description": "Persisted quote for the intent" - }, - "404": { - "description": "Intent not found or no quote persisted" + "description": "" } }, + "summary": "Most recent dead-lettered jobs for a queue", "tags": [ - "intents" + "admin" ] } }, - "/api/v1/intents/{id}/auction": { + "/admin/flags": { "get": { - "operationId": "IntentsController_getAuctionPrice", + "operationId": "FlagsController_list", "parameters": [ { - "name": "id", + "name": "x-admin-key", + "in": "header", "required": true, - "in": "path", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "responses": { "200": { - "description": "Current and accepted Dutch auction prices", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "intentId": { "type": "string" }, - "currentDstAmount": { "type": "string" }, - "acceptedDstAmount": { "type": "string" }, - "timestamp": { "type": "number" } - } - } - } - } - }, - "404": { "description": "Intent not found or does not use a Dutch auction" } + "description": "" + } }, - "tags": ["intents"] + "summary": "Managed flags with env default, override pin and stored rules", + "tags": [ + "admin" + ] } }, - "/api/v1/intents/{id}/accept": { - "post": { - "operationId": "IntentsController_accept", + "/admin/flags/{key}": { + "put": { + "description": "Returns 202-style { status: 'pending' } when a second approval is required.", + "operationId": "FlagsController_update", "parameters": [ { - "name": "id", + "name": "x-admin-key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", "required": true, "in": "path", "schema": { @@ -676,34 +2444,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AcceptIntentDto" + "$ref": "#/components/schemas/UpdateFlagDto" } } } }, "responses": { - "403": { - "description": "Solver not registered or inactive" - }, - "404": { - "description": "Intent not found" - }, - "409": { - "description": "Intent is not in open state" - }, - "410": { - "description": "Intent has expired" + "200": { + "description": "" } }, + "summary": "Set a flag's default and targeting rules", "tags": [ - "intents" + "admin" ] } }, - "/api/v1/intents/{id}/fill": { + "/admin/flags/change-requests/{id}/approve": { "post": { - "operationId": "IntentsController_fill", + "operationId": "FlagsController_approve", "parameters": [ + { + "name": "x-admin-key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "id", "required": true, @@ -713,44 +2481,31 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FillIntentDto" - } - } - } - }, "responses": { - "400": { - "description": "Fill amount below minimum" - }, - "403": { - "description": "Wrong solver for this intent" - }, - "404": { - "description": "Intent not found" - }, - "409": { - "description": "Intent is not in accepted state" - }, - "410": { - "description": "Fill window has expired" + "200": { + "description": "" } }, + "summary": "Approve a pending change (must be a different admin than the proposer)", "tags": [ - "intents" + "admin" ] } }, - "/api/v1/intents/{id}/cancel": { - "post": { - "operationId": "IntentsController_cancel", + "/admin/flags/{key}/audit": { + "get": { + "operationId": "FlagsController_auditLog", "parameters": [ { - "name": "id", + "name": "x-admin-key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "key", "required": true, "in": "path", "schema": { @@ -758,91 +2513,48 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CancelIntentDto" - } - } - } - }, "responses": { - "403": { - "description": "Unauthorized" - }, - "404": { - "description": "Intent not found" - }, - "409": { - "description": "Intent is not in open state" + "200": { + "description": "" } }, + "summary": "Audit trail for a flag, newest first", "tags": [ - "intents" + "admin" ] } }, - "/api/v1/intents/quote": { - "post": { - "operationId": "IntentsController_quote", + "/health/live": { + "get": { + "operationId": "HealthController_live", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QuoteRequestDto" - } - } - } - }, "responses": { "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QuoteResponseDto" - } - } - } - }, - "429": { - "description": "Rate limit exceeded — max 20 quote requests per 60 s per IP" + "description": "" } }, "tags": [ - "intents" + "health" ] } }, - "/api/v1/solvers": { - "post": { - "operationId": "SolversController_register", + "/health/ready": { + "get": { + "operationId": "HealthController_ready", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterSolverDto" - } - } - } - }, "responses": { - "201": { + "200": { "description": "" } }, "tags": [ - "solvers" + "health" ] - }, + } + }, + "/health/startup": { "get": { - "operationId": "SolversController_getLeaderboard", + "operationId": "HealthController_startup", "parameters": [], "responses": { "200": { @@ -850,190 +2562,335 @@ } }, "tags": [ - "solvers" + "health" ] } }, - "/api/v1/solvers/{address}": { + "/health": { "get": { - "operationId": "SolversController_getSolver", - "parameters": [ - { - "name": "address", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "operationId": "HealthController_check", + "parameters": [], "responses": { "200": { "description": "" } }, "tags": [ - "solvers" + "health" ] - }, - "patch": { - "operationId": "SolversController_updateSolver", - "parameters": [ - { - "name": "address", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateSolverDto" - } - } + } + }, + "/stats": { + "get": { + "operationId": "StatsController_getStats", + "parameters": [], + "responses": { + "200": { + "description": "" } }, + "tags": [ + "stats" + ] + } + }, + "/stats/treasury": { + "get": { + "operationId": "StatsController_getTreasuryStats", + "parameters": [], "responses": { "200": { - "description": "Updated solver record" - }, - "400": { - "description": "Invalid update body" - }, - "401": { - "description": "Missing or invalid signature" - }, - "404": { - "description": "Solver not found" + "description": "" } }, "tags": [ - "solvers" + "stats" ] } }, - "/api/v1/solvers/{address}/stats": { + "/stats/ws": { "get": { - "operationId": "SolversController_getSolverStats", - "parameters": [ - { - "name": "address", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "operationId": "StatsController_getWsStats", + "parameters": [], "responses": { "200": { "description": "" } }, "tags": [ - "solvers" + "stats" ] } }, - "/api/v1/solvers/{address}/deregister": { - "post": { - "operationId": "SolversController_deregisterSolver", + "/treasury/reconciliation": { + "get": { + "description": "Returns daily reconciliation summary showing expected vs actual balances per asset", + "operationId": "TreasuryController_getReconciliation", "parameters": [ { - "name": "address", - "required": true, - "in": "path", + "name": "date", + "required": false, + "in": "query", + "description": "Date in YYYY-MM-DD format (defaults to today)", "schema": { + "example": "2026-09-28", "type": "string" } } ], "responses": { - "201": { - "description": "" + "200": { + "description": "Reconciliation summary", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2026-09-28" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "asset": { + "type": "string", + "example": "native" + }, + "expectedBalance": { + "type": "string", + "example": "1000000000" + }, + "actualBalance": { + "type": "string", + "example": "1000500000" + }, + "discrepancy": { + "type": "string", + "example": "500000" + }, + "discrepancyPercentage": { + "type": "number", + "example": 0.05 + }, + "hasUnexplainedDiscrepancy": { + "type": "boolean", + "example": false + }, + "explanation": { + "type": "string", + "nullable": true + } + } + } + }, + "totalDiscrepancies": { + "type": "number", + "example": 3 + }, + "assetsWithUnexplainedDiscrepancies": { + "type": "number", + "example": 1 + }, + "lastReconciliationAt": { + "type": "string", + "example": "2026-09-28T00:00:00.000Z" + } + } + } + } + } } }, + "summary": "Get treasury reconciliation summary", "tags": [ - "solvers" + "treasury" ] } }, - "/api/v1/solvers/{address}/deactivate": { - "post": { - "operationId": "SolversController_deactivate", + "/treasury/reconciliation/{asset}": { + "get": { + "description": "Returns detailed reconciliation data including transaction breakdown", + "operationId": "TreasuryController_getAssetReconciliation", "parameters": [ { - "name": "address", + "name": "asset", "required": true, "in": "path", + "description": "Asset identifier (e.g., 'native', 'USDC:ISSUER', or contract address)", + "schema": { + "example": "native", + "type": "string" + } + }, + { + "name": "date", + "required": false, + "in": "query", + "description": "Date in YYYY-MM-DD format (defaults to today)", "schema": { + "example": "2026-09-28", "type": "string" } } ], "responses": { - "201": { - "description": "" + "200": { + "description": "Detailed reconciliation data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "snapshotDate": { + "type": "string", + "example": "2026-09-28" + }, + "asset": { + "type": "string", + "example": "native" + }, + "expectedBalance": { + "type": "string", + "example": "1000000000" + }, + "actualBalance": { + "type": "string", + "example": "1000500000" + }, + "discrepancy": { + "type": "string", + "example": "500000" + }, + "discrepancyPercentage": { + "type": "number", + "example": 0.05 + }, + "toleranceThreshold": { + "type": "string", + "example": "10000000" + }, + "hasUnexplainedDiscrepancy": { + "type": "boolean", + "example": false + }, + "explanation": { + "type": "string", + "nullable": true + }, + "breakdown": { + "type": "object", + "properties": { + "fees": { + "type": "string", + "example": "500000000" + }, + "slashes": { + "type": "string", + "example": "100000000" + }, + "refunds": { + "type": "string", + "example": "50000000" + } + } + }, + "recentTransactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fee", + "slash", + "refund" + ] + }, + "amount": { + "type": "string", + "example": "1000000" + }, + "timestamp": { + "type": "string", + "example": "2026-09-28T12:00:00.000Z" + }, + "reference": { + "type": "string", + "example": "intent-uuid" + } + } + } + } + } + } + } + } } }, + "summary": "Get detailed reconciliation for an asset", "tags": [ - "solvers" + "treasury" ] } }, - "/api/v1/solvers/{address}/reactivate": { + "/treasury/reconciliation/trigger": { "post": { - "operationId": "SolversController_reactivate", + "description": "Admin endpoint to trigger reconciliation on-demand", + "operationId": "TreasuryController_triggerReconciliation", "parameters": [ { - "name": "address", - "required": true, - "in": "path", + "name": "asset", + "required": false, + "in": "query", + "description": "Specific asset to reconcile (reconciles all if omitted)", "schema": { + "example": "native", "type": "string" } } ], - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "solvers" - ] - } - }, - "/api/v1/stats": { - "get": { - "operationId": "StatsController_getStats", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "stats" - ] - } - }, - "/api/v1/stats/ws": { - "get": { - "operationId": "StatsController_getWsStats", - "parameters": [], "responses": { "200": { - "description": "" + "description": "Reconciliation completed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Reconciliation completed" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "asset": { + "type": "string" + }, + "hasUnexplainedDiscrepancy": { + "type": "boolean" + }, + "discrepancy": { + "type": "string" + } + } + } + } + } + } + } + } } }, + "summary": "Manually trigger treasury reconciliation", "tags": [ - "stats" + "treasury" ] } } @@ -1078,69 +2935,83 @@ ], "components": { "schemas": { - "StellarTokenDto": { + "PauseKillSwitchDto": { "type": "object", "properties": { - "contract": { + "scope": { "type": "string", - "description": "Stellar contract ID of the token" + "enum": [ + "global", + "chain", + "token", + "operation" + ], + "description": "Breadth of the pause." }, - "symbol": { + "chain": { "type": "string", - "example": "XLM" + "description": "Required for chain/token/operation scope." }, - "name": { + "token": { "type": "string", - "example": "Stellar Lumens" + "description": "Required for token scope; wildcard for operation scope." }, - "decimals": { - "type": "number", - "example": 7 + "operation": { + "type": "string", + "enum": [ + "create", + "accept", + "fill", + "slash", + "onchain" + ], + "description": "Required for operation scope." }, - "priceUSD": { - "type": "number", - "example": 0.1182 + "reasonCode": { + "type": "string", + "enum": [ + "INCIDENT", + "TOKEN_DEPEGGED", + "SOLVER_INCIDENT", + "CHAIN_DEGRADED", + "RPC_DEGRADED", + "REGULATORY", + "MAINTENANCE" + ] + }, + "reason": { + "type": "string", + "description": "Operator explanation, shown to clients and in the audit log." } }, "required": [ - "contract", - "symbol", - "name", - "decimals", - "priceUSD" + "scope", + "reasonCode", + "reason" ] }, - "StellarTokensResponseDto": { + "ApproveResumeDto": { "type": "object", "properties": { - "tokens": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StellarTokenDto" - } + "note": { + "type": "string", + "description": "Why the resume is safe." + }, + "approvalsRequired": { + "type": "number", + "default": 2, + "minimum": 2, + "description": "Distinct approvals needed." } - }, - "required": [ - "tokens" - ] - }, - "DutchAuctionDto": { - "type": "object", - "properties": { - "startDstAmount": { "type": "string", "description": "Starting destination amount in base units" }, - "decayStart": { "type": "number", "description": "Unix timestamp when the price decay starts" }, - "decayEnd": { "type": "number", "description": "Unix timestamp when the price reaches minDstAmount" }, - "exclusiveSolver": { "type": "string", "maxLength": 56, "description": "Solver address exclusively eligible until exclusivityEnd" }, - "exclusivityEnd": { "type": "number", "description": "Unix timestamp when solver exclusivity ends" } - }, - "required": ["startDstAmount", "decayStart", "decayEnd"] + } }, "CreateIntentDto": { "type": "object", "properties": { "user": { "type": "string", - "description": "Stellar or EVM address of the user creating the intent" + "description": "Stellar address of the user creating the intent", + "maxLength": 56 }, "srcChain": { "type": "string", @@ -1161,7 +3032,8 @@ }, "srcTokenSymbol": { "type": "string", - "description": "Source token symbol, e.g. USDC" + "description": "Source token symbol, e.g. USDC", + "maxLength": 16 }, "srcTokenDecimals": { "type": "number", @@ -1175,11 +3047,13 @@ }, "dstTokenContract": { "type": "string", - "description": "Destination Stellar token contract" + "description": "Destination Stellar token contract", + "maxLength": 56 }, "dstTokenSymbol": { "type": "string", - "description": "Destination token symbol, e.g. USDC" + "description": "Destination token symbol, e.g. USDC", + "maxLength": 16 }, "dstTokenDecimals": { "type": "number", @@ -1191,17 +3065,10 @@ "type": "string", "description": "Minimum acceptable destination amount as an integer string" }, - "auction": { - "$ref": "#/components/schemas/DutchAuctionDto", - "description": "Optional Dutch-auction allocation terms" - }, "deadline": { "type": "number", "description": "Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h" }, - "signature": { "type": "string", "maxLength": 4096, "description": "EIP-712 signature for EVM-originated intent creation" }, - "nonce": { "type": "string", "maxLength": 128, "description": "Unique nonce included in the EIP-712 signature" }, - "expiresAt": { "type": "number", "description": "Unix timestamp when the EIP-712 signature expires" }, "idempotencyKey": { "type": "string", "description": "Idempotency key for deduplicating duplicate requests" @@ -1220,18 +3087,93 @@ "minDstAmount" ] }, + "BatchLookupDto": { + "type": "object", + "properties": { + "intentIds": { + "maxItems": 100, + "description": "Intent IDs to look up (max 100). IDs with no matching record are omitted from the response, not individually 404'd.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "intentIds" + ] + }, + "BatchCreateIntentsDto": { + "type": "object", + "properties": { + "intents": { + "description": "List of intents to create atomically (1..50)", + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateIntentDto" + } + } + }, + "required": [ + "intents" + ] + }, + "BatchCreateItemErrorDto": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "Zero-based index of the failed intent item in the input array" + }, + "field": { + "type": "string", + "description": "Field name associated with the error, if applicable" + }, + "message": { + "type": "string", + "description": "Human-readable error description" + } + }, + "required": [ + "index", + "message" + ] + }, + "BatchCreateIntentsResponseDto": { + "type": "object", + "properties": { + "created": { + "description": "Array of created Intent objects when successful", + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "description": "List of per-item validation errors if any failed", + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchCreateItemErrorDto" + } + } + }, + "required": [ + "created", + "errors" + ] + }, "AcceptIntentDto": { "type": "object", "properties": { "solver": { "type": "string", - "description": "Solver address accepting the intent" + "description": "Solver address accepting the intent", + "maxLength": 56 }, - "nonce": { "type": "string", "minLength": 16, "maxLength": 128, "description": "Single-use signing nonce" }, - "expiresAt": { "type": "number", "description": "Unix timestamp when the signature expires" }, "signature": { "type": "string", - "description": "Base64-encoded Ed25519 signature of the signed intent action" + "description": "Base64-encoded Ed25519 signature of the message \"accept::\" produced by the solver's private key", + "maxLength": 88 } }, "required": [ @@ -1244,7 +3186,8 @@ "properties": { "solver": { "type": "string", - "description": "Solver address filling the intent (must match the accepting solver)" + "description": "Solver address filling the intent (must match the accepting solver)", + "maxLength": 56 }, "fillAmount": { "type": "string", @@ -1252,13 +3195,13 @@ }, "txHash": { "type": "string", - "description": "Stellar fill transaction hash" + "description": "Stellar fill transaction hash", + "maxLength": 128 }, - "nonce": { "type": "string", "minLength": 16, "maxLength": 128, "description": "Single-use signing nonce" }, - "expiresAt": { "type": "number", "description": "Unix timestamp when the signature expires" }, "signature": { "type": "string", - "description": "Base64-encoded Ed25519 signature of the signed intent action" + "description": "Base64-encoded Ed25519 signature of the message \"fill::\" produced by the solver's private key", + "maxLength": 88 } }, "required": [ @@ -1272,18 +3215,46 @@ "properties": { "user": { "type": "string", - "description": "Stellar or EVM address of the intent's original creator (must match)" + "description": "Stellar address of the intent's original creator (must match)", + "maxLength": 56 + }, + "signature": { + "type": "string", + "description": "Base64-encoded Ed25519 signature of the message \"cancel:\" produced by the private key of `user`", + "maxLength": 88 + } + }, + "required": [ + "user", + "signature" + ] + }, + "AmendIntentDto": { + "type": "object", + "properties": { + "user": { + "type": "string", + "description": "Stellar address of the intent's original creator (must match)", + "maxLength": 56 + }, + "minDstAmount": { + "type": "string", + "description": "Replacement minimum destination amount in base units" + }, + "deadline": { + "type": "number", + "description": "Replacement Unix timestamp deadline" }, - "nonce": { "type": "string", "minLength": 16, "maxLength": 128, "description": "Single-use signing nonce" }, - "expiresAt": { "type": "number", "description": "Unix timestamp when the signature expires" }, "signature": { "type": "string", - "maxLength": 4096, - "description": "Base64 Ed25519 or EIP-712 signature proving control of user" + "description": "Base64 Ed25519 signature of \"amend::::\"", + "maxLength": 88 } }, "required": [ "user", + "minDstAmount", + "deadline", "signature" ] }, @@ -1517,6 +3488,10 @@ "priceImpact": { "type": "number", "description": "Price impact for the best quote as a decimal fraction (0 when no quote available)" + }, + "indicative": { + "type": "boolean", + "description": "True when no solver responded and the returned quote is indicative" } }, "required": [ @@ -1536,11 +3511,13 @@ "properties": { "address": { "type": "string", - "description": "Solver's Stellar address" + "description": "Solver's Stellar address", + "maxLength": 56 }, "name": { "type": "string", - "description": "Solver's display name" + "description": "Solver's display name", + "maxLength": 64 }, "bondAmount": { "type": "string", @@ -1575,7 +3552,8 @@ }, "proofSignature": { "type": "string", - "description": "Proof-of-control signature for the advertised solver address" + "description": "Proof-of-control signature for the advertised solver address", + "maxLength": 88 } }, "required": [ @@ -1630,6 +3608,103 @@ "required": [ "signature" ] + }, + "UpdateSolverStatusDto": { + "type": "object", + "properties": { + "signature": { + "type": "string", + "description": "Proof-of-control signature for the solver status update", + "maxLength": 88 + } + }, + "required": [ + "signature" + ] + }, + "StellarTokenDto": { + "type": "object", + "properties": { + "contract": { + "type": "string", + "description": "Stellar contract ID of the token" + }, + "symbol": { + "type": "string", + "example": "XLM" + }, + "name": { + "type": "string", + "example": "Stellar Lumens" + }, + "decimals": { + "type": "number", + "example": 7 + }, + "priceUSD": { + "type": "number", + "example": 0.1182 + } + }, + "required": [ + "contract", + "symbol", + "name", + "decimals", + "priceUSD" + ] + }, + "StellarTokensResponseDto": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StellarTokenDto" + } + } + }, + "required": [ + "tokens" + ] + }, + "GuardianOverrideDto": { + "type": "object", + "properties": {} + }, + "CreateSolverCredentialDto": { + "type": "object", + "properties": { + "scopes": { + "type": "string", + "enum": [ + "solver:read", + "intents:read", + "quote:respond", + "intents:accept", + "intents:fill" + ], + "description": "Scopes granted to the credential" + }, + "ipAllowlist": { + "description": "Optional source-address allowlist. Entries are an exact IPv4 address, an IPv4 CIDR block, an exact IPv6 address, or `*` for any source. IPv6 CIDR blocks and hostnames are not supported; an entry that does not parse never matches, so a typo locks the credential out rather than opening it up.", + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "number", + "description": "Unix epoch seconds at which the credential expires (null = never)" + } + }, + "required": [ + "scopes" + ] + }, + "UpdateFlagDto": { + "type": "object", + "properties": {} } } } diff --git a/src/health/ws-gateway-health.indicator.ts b/src/health/ws-gateway-health.indicator.ts index 5d2c1e72..7fe731c2 100644 --- a/src/health/ws-gateway-health.indicator.ts +++ b/src/health/ws-gateway-health.indicator.ts @@ -1,47 +1,29 @@ import { Injectable } from "@nestjs/common"; import { IntentsGateway } from "../intents/intents.gateway"; -import { HealthIndicator, HealthResult } from "./health-indicator.registry"; +import { HealthIndicator, IndicatorResult, ServiceRole } from "./health-indicator.registry"; /** - * Health indicator for WebSocket gateway (Activity 2). - * + * Health indicator for the WebSocket gateway (issue #511). + * * Tracks draining state for graceful shutdown: - * - During drain, readiness returns "not_ready" so load balancer stops routing - * - Existing connections receive server_draining and close gradually + * - During drain, readiness returns "down" so the load balancer stops routing + * new connections to this replica + * - Existing connections receive `server_draining` and close gradually */ @Injectable() export class WsGatewayHealthIndicator implements HealthIndicator { - constructor(private readonly gateway: IntentsGateway) {} - - name(): string { - return "ws_gateway"; - } + readonly name = "ws_gateway"; + /** Critical for the `ws` role: a draining gateway must not receive traffic. */ + readonly criticalFor: ServiceRole[] = ["ws"]; - critical(): boolean { - return true; // Critical for "ws" role - } - - async check(): Promise { - const isDraining = this.gateway.isDraining(); + constructor(private readonly gateway: IntentsGateway) {} - if (isDraining) { - return { - status: "down", - message: "WebSocket gateway is draining connections", - details: { - draining: true, - subscribers: this.gateway.subscriberCount, - }, - }; - } + async check(): Promise { + const draining = this.gateway.isDraining(); + const subscribers = this.gateway.subscriberCount; - return { - status: "up", - message: "WebSocket gateway accepting connections", - details: { - draining: false, - subscribers: this.gateway.subscriberCount, - }, - }; + return draining + ? { status: "down", details: { draining: true, subscribers } } + : { status: "up", details: { draining: false, subscribers } }; } } diff --git a/src/intents/dto/list-intents.dto.ts b/src/intents/dto/list-intents.dto.ts index dfab51e9..4906763d 100644 --- a/src/intents/dto/list-intents.dto.ts +++ b/src/intents/dto/list-intents.dto.ts @@ -1,91 +1,3 @@ -import { IsEnum, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; -import { Transform, Type } from "class-transformer"; -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IntentState } from "../intents.types"; -import { DEFAULT_PAGE_SIZE, MAX_OFFSET, MAX_PAGE_SIZE } from "../../common/pagination"; - -/** - * Query-parameter DTO for `GET /api/v1/intents` and - * `GET /api/v1/intents/user/:addr` (#412). - * - * Cursor-based pagination replaces the old `offset` query param. The - * `offset` param is still accepted but deprecated: - * - Requests using `offset` receive a `Deprecation` response header. - * - `offset` is hard-capped at MAX_OFFSET (10 000); requests above that - * are rejected with HTTP 400. - * - * Stable ordering: `(createdAt DESC, intentId ASC)` — a tie-breaker is - * needed because two intents can share the same createdAt second. - */ -export class ListIntentsDto { - /** - * Filter by intent state. Omit to return all states. - */ - @ApiPropertyOptional({ - enum: ["open", "accepted", "filled", "cancelled", "expired", "slashed"], - description: "Return only intents in this state", - }) - @IsOptional() - @IsIn(["open", "accepted", "filled", "cancelled", "expired", "slashed"]) - state?: IntentState; - - /** - * Maximum number of items to return (1–100, default 25). - */ - @ApiPropertyOptional({ - type: Number, - minimum: 1, - maximum: MAX_PAGE_SIZE, - default: DEFAULT_PAGE_SIZE, - description: `Page size (1–${MAX_PAGE_SIZE}, default ${DEFAULT_PAGE_SIZE})`, - }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(MAX_PAGE_SIZE) - limit?: number; - - /** - * Opaque keyset cursor returned by the previous page's `nextCursor`. - * Mutually exclusive with `offset`. - */ - @ApiPropertyOptional({ - type: String, - description: "Opaque cursor from the previous page's `nextCursor` field", - }) - @IsOptional() - @IsString() - @MaxLength(512) - cursor?: string; - - /** - * @deprecated Use `cursor` instead. - * - * Offset-based skip value. Capped at MAX_OFFSET; requests above that are - * rejected with 400. A `Deprecation` header is included in every response - * when this parameter is present. - */ - @ApiPropertyOptional({ - type: Number, - deprecated: true, - description: `@deprecated — use cursor instead. Capped at ${MAX_OFFSET}.`, - }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(0) - @Max(MAX_OFFSET) - offset?: number; - - /** - * Filter by source chain. - */ - @ApiPropertyOptional({ type: String, description: "Filter by source chain" }) - @IsOptional() - @IsString() - @MaxLength(32) - chain?: string; import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; import { ApiPropertyOptional } from "@nestjs/swagger"; import { diff --git a/src/intents/dual-write-intents.repository.spec.ts b/src/intents/dual-write-intents.repository.spec.ts index 0b410460..8ad91bb5 100644 --- a/src/intents/dual-write-intents.repository.spec.ts +++ b/src/intents/dual-write-intents.repository.spec.ts @@ -21,7 +21,7 @@ class FakeSecondary extends InMemoryIntentsRepository { async saveIfNewer(intent: Intent): Promise { if (this.failWrites) throw new Error("postgres unavailable"); const current = this.findById(intent.intentId); - if (!current || current.version < intent.version) this.save(intent); + if (!current || (current.version ?? 0) < (intent.version ?? 0)) this.save(intent); } override createIdempotent(intent: Intent, key: string, minCreatedAt: number) { diff --git a/src/intents/dual-write-intents.repository.ts b/src/intents/dual-write-intents.repository.ts index e12e7225..cc603763 100644 --- a/src/intents/dual-write-intents.repository.ts +++ b/src/intents/dual-write-intents.repository.ts @@ -47,7 +47,7 @@ export class DualWriteIntentsRepository implements IIntentsRepository { let loadedFromPostgres = 0; for (const intent of fromDb) { const local = this.primary.findById(intent.intentId); - if (!local || local.version < intent.version) { + if (!local || (local.version ?? 0) < (intent.version ?? 0)) { this.primary.save(intent); loadedFromPostgres++; } @@ -116,7 +116,7 @@ export class DualWriteIntentsRepository implements IIntentsRepository { return this.primary.countActiveByUser(user); } - update(id: string, patch: IntentPatch, expectedVersion: number): Promise { + update(id: string, patch: IntentPatch, expectedVersion?: number): Promise { return this.mirrored("update", this.primary.update(id, patch, expectedVersion)); } diff --git a/src/intents/etag.ts b/src/intents/etag.ts index 6defd215..d6ea2e01 100644 --- a/src/intents/etag.ts +++ b/src/intents/etag.ts @@ -10,7 +10,7 @@ import { VersionConflict } from "./intents.repository"; * endpoints; a mismatch yields `412 Precondition Failed` (RFC 9110 §13.1.1). */ export function etagFor(intent: Pick): string { - return `"${intent.version}"`; + return `"${intent.version ?? 0}"`; } /** diff --git a/src/intents/in-memory-intents.repository.spec.ts b/src/intents/in-memory-intents.repository.spec.ts index 78870234..77332ccf 100644 --- a/src/intents/in-memory-intents.repository.spec.ts +++ b/src/intents/in-memory-intents.repository.spec.ts @@ -113,9 +113,10 @@ describe("InMemoryIntentsRepository", () => { repo.save(makeIntent({ intentId: "upd-1", state: "open" })); const updated = repo.update("upd-1", { state: "accepted", solver: "SOLVER_X" }); + if (!updated || "kind" in updated) throw new Error("update should have succeeded"); - expect(updated?.state).toBe("accepted"); - expect(updated?.solver).toBe("SOLVER_X"); + expect(updated.state).toBe("accepted"); + expect(updated.solver).toBe("SOLVER_X"); expect(repo.findById("upd-1")?.state).toBe("accepted"); }); @@ -157,6 +158,4 @@ describe("InMemoryIntentsRepository", () => { expect(repo.amendIfOpen("expired", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull(); expect(repo.amendIfOpen("missing", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull(); }); -}); - }); }); diff --git a/src/intents/intent-pending-states.spec.ts b/src/intents/intent-pending-states.spec.ts index 764cc972..b7187d0a 100644 --- a/src/intents/intent-pending-states.spec.ts +++ b/src/intents/intent-pending-states.spec.ts @@ -19,6 +19,7 @@ import { Intent, IntentState, INTENT_STATES } from "./intents.types"; import { AppConfig } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; +import { ProtocolParamsService } from "../governance/params.service"; // ── helpers ──────────────────────────────────────────────────────────────── @@ -68,7 +69,18 @@ function makeService( }); const stellarTx = opts.stellarTx ?? fakeStellarTx(); const prisma = fakePrisma(); - return new IntentsService(repo, config, stellarTx, prisma); + // Governance snapshot (issue #500): creation stamps `paramsVersion` from it, + // so every harness that creates intents must provide one. + const protocolParams = { + snapshotForChain: jest.fn().mockReturnValue({ + version: 0, + feeBps: 30, + deadlineSeconds: 1800, + fillWindowSeconds: 600, + capturedAt: new Date().toISOString(), + }), + } as unknown as ProtocolParamsService; + return new IntentsService(repo, config, stellarTx, prisma, protocolParams); } function validCreateData(): Omit { diff --git a/src/intents/intents-deadline.jobs.spec.ts b/src/intents/intents-deadline.jobs.spec.ts index 13207613..07aef74d 100644 --- a/src/intents/intents-deadline.jobs.spec.ts +++ b/src/intents/intents-deadline.jobs.spec.ts @@ -47,6 +47,7 @@ function buildIntents(scheduler: IntentDeadlineScheduler): IntentsService { undefined, undefined, undefined, + undefined, scheduler, ); } @@ -66,6 +67,7 @@ describe("deadline jobs", () => { intents, { broadcast: jest.fn().mockResolvedValue(undefined) } as unknown as IntentsGateway, {} as SolversService, + null, { slashSolver: jest.fn().mockResolvedValue({ detail: "no-op" }) } as unknown as SolverRegistryService, metrics, { evaluateTarget: jest.fn().mockReturnValue({ paused: false, matched: null, matchedChain: [] }) } as unknown as KillSwitchService, diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index 57f2b269..a94d8826 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -8,13 +8,18 @@ import { logger } from "../common/logger"; import { MetricsService } from "../metrics/metrics.service"; import { KillSwitchService } from "../killswitch/killswitch.service"; import { Intent } from "./intents.types"; +import { DeadlineJobData, DEADLINE_QUEUE, EXPIRE_INTENT_JOB, FILL_WINDOW_EXPIRED_JOB } from "./intents-deadline.jobs"; +import { JobsService } from "../jobs/jobs.service"; import { + AppConfig, CHAIN_FILL_WINDOW_DEFAULTS, DEFAULT_FILL_WINDOW_SECONDS, } from "../config/configuration"; import { LeaderElectionService, Singleton } from "../common/leader-election"; +import { ConfigService } from "@nestjs/config"; -const SWEEP_INTERVAL_MS = 30_000; +/** Low-frequency safety scan. Deadline jobs are the primary expiry path (issue #437). */ +const SAFETY_SWEEP_INTERVAL_MS = 300_000; /** Outcome of a single sweep cycle — returned so a manual trigger can log it. */ export interface SweepResult { @@ -40,9 +45,14 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { private readonly metricsService: MetricsService, private readonly killSwitch: KillSwitchService, private readonly leaderElection: LeaderElectionService, + @Optional() private readonly config?: ConfigService, + @Optional() private readonly jobs?: JobsService, ) {} onModuleInit() { + this.jobs?.defineQueue(DEADLINE_QUEUE, { concurrency: 8 }); + this.jobs?.process(EXPIRE_INTENT_JOB, (data) => this.handleExpireJob(data)); + this.jobs?.process(FILL_WINDOW_EXPIRED_JOB, (data) => this.handleFillWindowJob(data)); this.leaderElection.registerWorker("sweeper", (isLeader, _token) => { if (isLeader) { this.logger.log("[sweeper] became leader — starting interval"); @@ -61,10 +71,10 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { private startInterval(): void { if (this.interval) return; // already running this.interval = setInterval(() => { - this.sweep().catch((err) => { + this.sweep({ safety: true }).catch((err) => { logger.error(`[sweeper] sweep failed: ${err instanceof Error ? err.message : err}`); }); - }, SWEEP_INTERVAL_MS); + }, this.safetyIntervalMs()); } private stopInterval(): void { @@ -74,7 +84,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { } } - async sweep(): Promise { + async sweep(options?: { safety?: boolean }): Promise { const startMs = Date.now(); const now = Math.floor(startMs / 1000); let expiredCount = 0; @@ -82,22 +92,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { let extendedDeadlines = 0; for (const intent of await this.intentsService.getByState("open")) { - if (intent.deadline <= now) { - // Atomic guard: a concurrent user cancel() or solver accept() may have - // already transitioned this intent out of "open" — skip it if so. - const expired = await this.intentsService.expireIfOpen(intent.intentId); - if (!expired) continue; - // Audit trail (issue #62): system-driven expiration. - this.intentsService.appendAuditEntry( - intent.intentId, - "expired", - "system", - "deadline passed", - { deadline: intent.deadline, sweepedAt: now }, - ); - expiredCount++; - await this.intentsGateway.broadcast({ type: "intent_expired", intentId: intent.intentId }); - } + if (intent.deadline <= now && (await this.expireOpen(intent, now))) expiredCount++; } const durationMs = Date.now() - startMs; @@ -122,26 +117,13 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { // its window instead of slashing; the intent becomes fillable again on // resume. Evaluated per intent because a pause may be scoped to a single // chain or token. - const deadline = this.pausedFillDeadline(intent, now); - if (deadline !== null) { - const extended = await this.intentsService.extendDeadlineIfAccepted( - intent.intentId, - deadline, - ); - if (extended) { - extendedDeadlines++; - this.logger.warn( - `[sweeper] intent ${intent.intentId} fill is paused by a kill-switch — ` + - `slashing suppressed and deadline extended to ${deadline}`, - ); - } - continue; - } - - const slashed = await this.slashMissedFill(intent.intentId, intent.solver, now); - if (slashed) slashedCount++; + const outcome = await this.settleAccepted(intent, now); + if (outcome === "slashed") slashedCount++; + if (outcome === "extended") extendedDeadlines++; } + if (options?.safety) this.metricsService.recordSafetyCatch?.(expiredCount + slashedCount); + return { expiredCount, slashedCount, @@ -169,6 +151,65 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { return now + window; } + /** + * `expire-intent` handler. No-ops when the intent is no longer open or the + * stored deadline is not the one this job was armed for (amendment / stale job). + */ + async handleExpireJob(data: DeadlineJobData): Promise { + const intent = await this.intentsService.get(data.intentId); + if (!intent || intent.state !== "open" || intent.deadline !== data.deadline) return; + const now = Math.floor(Date.now() / 1000); + if (intent.deadline > now) return; + await this.expireOpen(intent, now); + } + + /** + * `fill-window-expired` handler. No-ops on a terminal state or a deadline + * that has since been moved. A kill-switch pause extends the window instead + * of slashing, and that extension arms a new job. + */ + async handleFillWindowJob(data: DeadlineJobData): Promise { + const intent = await this.intentsService.get(data.intentId); + if (!intent || intent.state !== "accepted" || intent.deadline !== data.deadline) return; + const now = Math.floor(Date.now() / 1000); + if (intent.deadline > now) return; + await this.settleAccepted(intent, now); + } + + private safetyIntervalMs(): number { + const configured = this.config?.get("safetySweepIntervalMs", { infer: true }); + return configured && configured > 0 ? configured : SAFETY_SWEEP_INTERVAL_MS; + } + + private async expireOpen(intent: Intent, now: number): Promise { + const expired = await this.intentsService.expireIfOpen(intent.intentId); + if (!expired) return false; + this.intentsService.appendAuditEntry( + intent.intentId, + "expired", + "system", + "deadline passed", + { deadline: intent.deadline, sweepedAt: now }, + ); + await this.intentsGateway.broadcast({ type: "intent_expired", intentId: intent.intentId }); + return true; + } + + private async settleAccepted(intent: Intent, now: number): Promise<"slashed" | "extended" | "skipped"> { + const deadline = this.pausedFillDeadline(intent, now); + if (deadline !== null) { + const extended = await this.intentsService.extendDeadlineIfAccepted(intent.intentId, deadline); + if (!extended) return "skipped"; + this.logger.warn( + `[sweeper] intent ${intent.intentId} fill is paused by a kill-switch — ` + + `slashing suppressed and deadline extended to ${deadline}`, + ); + return "extended"; + } + const slashed = await this.slashMissedFill(intent.intentId, intent.solver, now); + return slashed ? "slashed" : "skipped"; + } + /** * Issue #269 — safe, auditable manual sweep trigger (operator break-glass). * diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index a915fb49..5f5e3142 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -1,260 +1,16 @@ import { BadRequestException, Body, - Controller, - Get, - HttpCode, - NotFoundException, - Param, - Post, - Query, - Res, -} from "@nestjs/common"; -import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags } from "@nestjs/swagger"; -import type { Response } from "express"; -import { IntentsService } from "./intents.service"; -import { BatchLookupDto } from "./dto/batch-lookup.dto"; -import { AcceptIntentDto } from "./dto/accept-intent.dto"; -import { ListIntentsDto } from "./dto/list-intents.dto"; -import { Intent } from "./intents.types"; -import { - PaginatedResponse, - encodeCursor, - decodeCursor, - hashFilter, - getCursorSecret, - DEFAULT_PAGE_SIZE, - MAX_OFFSET, -} from "../common/pagination"; - -/** - * REST controller for intent lifecycle (#412 pagination, #410 FK columns). - * - * All list endpoints use keyset pagination. Legacy `offset` params are still - * accepted but deprecated: callers receive a `Deprecation` response header. - */ -@ApiTags("intents") -@Controller("api/v1/intents") -export class IntentsController { - constructor(private readonly intentsService: IntentsService) {} - - // ─── List intents ───────────────────────────────────────────────────────── - - @Get() - @ApiOperation({ summary: "List intents (keyset-paginated)" }) - async list( - @Query() query: ListIntentsDto, - @Res({ passthrough: true }) res: Response, - ): Promise> { - return this.listPage(query, res); - } - - // ─── Get by user ────────────────────────────────────────────────────────── - - @Get("user/:addr") - @ApiOperation({ summary: "List intents for a user (keyset-paginated)" }) - @ApiParam({ name: "addr", description: "User Stellar or EVM address" }) - async listByUser( - @Param("addr") addr: string, - @Query() query: ListIntentsDto, - @Res({ passthrough: true }) res: Response, - ): Promise> { - return this.listPage({ ...query, user: addr }, res); - } - - // ─── Get single intent ──────────────────────────────────────────────────── - - @Get(":id") - @ApiOperation({ summary: "Get intent by ID" }) - async getById(@Param("id") id: string): Promise { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException(`Intent ${id} not found`); - return intent; - } - - // ─── Batch lookup ───────────────────────────────────────────────────────── - - @Post("batch") - @HttpCode(200) - @ApiOperation({ summary: "Batch-fetch intents by IDs" }) - async batchLookup(@Body() dto: BatchLookupDto): Promise { - return this.intentsService.getMany(dto.intentIds); - } - - // ─── Audit log ──────────────────────────────────────────────────────────── - - /** - * GET /api/v1/intents/:id/audit — keyset-paginated audit log (#412). - * - * Returns audit entries for the given intent in newest-first order. - * The `offset` param is deprecated; use `cursor` instead. - */ - @Get(":id/audit") - @ApiOperation({ summary: "Intent audit log (keyset-paginated)" }) - @ApiQuery({ name: "limit", required: false, type: Number }) - @ApiQuery({ name: "cursor", required: false, type: String }) - @ApiQuery({ name: "offset", required: false, type: Number, deprecated: true }) - async getAuditLog( - @Param("id") id: string, - @Query("limit") rawLimit?: string, - @Query("cursor") cursor?: string, - @Query("offset") rawOffset?: string, - @Res({ passthrough: true }) res?: Response, - ): Promise> { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException(`Intent ${id} not found`); - - const limit = Math.min(parseInt(rawLimit ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE, 100); - const offset = rawOffset !== undefined ? parseInt(rawOffset, 10) : undefined; - - if (offset !== undefined && !Number.isNaN(offset)) { - if (offset > MAX_OFFSET) { - throw new BadRequestException(`offset exceeds maximum of ${MAX_OFFSET}; use cursor pagination`); - } - res?.setHeader("Deprecation", "true"); - res?.setHeader("Link", `; rel="successor-version"`); - } - - const allEntries = this.intentsService.getAuditLog(id); - const skip = cursor - ? this.auditCursorToOffset(cursor, id) - : (offset ?? 0); - const page = allEntries.slice(skip, skip + limit); - const hasMore = skip + limit < allEntries.length; - const nextCursor = hasMore - ? this.encodeAuditCursor(skip + limit, id) - : null; - - return new PaginatedResponse(page, nextCursor); - } - - // ─── Accept / Fill / Cancel ─────────────────────────────────────────────── - - @Post(":id/accept") - @HttpCode(200) - @ApiOperation({ summary: "Accept an intent" }) - @ApiHeader({ name: "X-Idempotency-Key", required: false }) - async accept( - @Param("id") id: string, - @Body() dto: AcceptIntentDto, - ): Promise { - const updated = await this.intentsService.acceptIfOpen(id, dto.solver); - if (!updated) throw new BadRequestException("Intent is not open or past deadline"); - return updated; - } - - @Post(":id/cancel") - @HttpCode(200) - @ApiOperation({ summary: "Cancel an open intent" }) - async cancel(@Param("id") id: string): Promise { - const updated = await this.intentsService.cancelIfOpen(id); - if (!updated) throw new BadRequestException("Intent is not open"); - return updated; - } - - // ─── Private helpers ────────────────────────────────────────────────────── - - /** - * Core list logic shared by GET /intents and GET /intents/user/:addr. - */ - private async listPage( - query: ListIntentsDto & { user?: string }, - res: Response, - ): Promise> { - const limit = Math.min(query.limit ?? DEFAULT_PAGE_SIZE, 100); - const secret = getCursorSecret(); - - // Build a filter fingerprint to bind the cursor. - const filterObj: Record = {}; - if (query.state) filterObj.state = query.state; - if (query.user) filterObj.user = query.user; - if (query.chain) filterObj.chain = query.chain; - const filterHash = hashFilter(filterObj); - - // Deprecated offset fallback. - let offset: number | undefined; - if (query.offset !== undefined) { - if (query.offset > MAX_OFFSET) { - throw new BadRequestException(`offset exceeds maximum of ${MAX_OFFSET}; use cursor pagination`); - } - res.setHeader("Deprecation", "true"); - res.setHeader("Link", "; rel=\"successor-version\""); - offset = query.offset; - } - - // Decode cursor position. - let cursorPayload: { createdAt: number; id: string } | undefined; - if (query.cursor) { - cursorPayload = decodeCursor(query.cursor, secret, filterHash); - } - - // Fetch all matching intents (sorted createdAt DESC, intentId ASC). - let all: Intent[]; - if (query.state) { - all = await this.intentsService.getByState(query.state); - } else if (query.user) { - all = await this.intentsService.getByUser(query.user); - } else { - all = await this.intentsService.getAll(); - } - - // Apply chain filter in-memory (fast path for in-memory adapter). - if (query.chain) { - all = all.filter((i) => i.srcChain === query.chain); - } - - // Sort: createdAt DESC, intentId ASC (stable tie-breaker). - all.sort((a, b) => { - if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt; - return a.intentId.localeCompare(b.intentId); - }); - - // Seek to cursor position. - let startIdx = offset ?? 0; - if (cursorPayload) { - const pos = all.findIndex( - (i) => i.createdAt < cursorPayload!.createdAt || - (i.createdAt === cursorPayload!.createdAt && i.intentId > cursorPayload!.id), - ); - startIdx = pos === -1 ? all.length : pos; - } - - const page = all.slice(startIdx, startIdx + limit); - const hasMore = startIdx + limit < all.length; - - let nextCursor: string | null = null; - if (hasMore && page.length > 0) { - const last = page[page.length - 1]; - nextCursor = encodeCursor({ createdAt: last.createdAt, id: last.intentId, filterHash }, secret); - } - - return new PaginatedResponse(page, nextCursor); - } - - /** - * Encode an audit-log offset as an opaque cursor. - * The cursor is intentId-scoped so it cannot be used against a different intent. - */ - private encodeAuditCursor(offset: number, intentId: string): string { - const secret = getCursorSecret(); - return encodeCursor({ createdAt: offset, id: intentId, filterHash: hashFilter({ intentId }) }, secret); - } - - private auditCursorToOffset(cursor: string, intentId: string): number { - const secret = getCursorSecret(); - const filterHash = hashFilter({ intentId }); - const payload = decodeCursor(cursor, secret, filterHash); - return payload.createdAt; // createdAt field holds the offset for audit log cursors ConflictException, Controller, ForbiddenException, Get, GoneException, NotFoundException, - Optional, Param, Post, Query, + UnprocessableEntityException, UseGuards, } from "@nestjs/common"; import { @@ -268,28 +24,35 @@ import { ApiTooManyRequestsResponse, ApiOperation, ApiServiceUnavailableResponse, + ApiUnprocessableEntityResponse, } from "@nestjs/swagger"; import { Throttle } from "@nestjs/throttler"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; -import { SolverGriefingService } from "../solvers/solver-griefing.service"; import { TokensService } from "../tokens/tokens.service"; import { RoutingService } from "../routing/routing.service"; -import { MAX_OPEN_INTENTS_PER_USER } from "./intents.service"; +import { MAX_OPEN_INTENTS_PER_USER, NewIntentData } from "./intents.service"; import { CreateIntentDto } from "./dto/create-intent.dto"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { AcceptIntentDto } from "./dto/accept-intent.dto"; import { FillIntentDto } from "./dto/fill-intent.dto"; import { CancelIntentDto } from "./dto/cancel-intent.dto"; +import { AmendIntentDto } from "./dto/amend-intent.dto"; import { QuoteRequestDto } from "./dto/quote-request.dto"; import { QuoteResponseDto } from "./dto/quote-response.dto"; import { ListIntentsDto } from "./dto/list-intents.dto"; import { BatchLookupDto } from "./dto/batch-lookup.dto"; +import { + BatchCreateIntentsDto, + BatchCreateIntentsResponseDto, +} from "./dto/batch-create-intents.dto"; +import { BATCH_CREATE_MAX_INTENTS } from "../config/limits.config"; import { UserThrottlerGuard } from "./user-throttler.guard"; import { verifyStellarSignature, buildAcceptMessage, + buildAmendMessage, buildCancelMessage, buildFillMessage, } from "../common/stellar-signature"; @@ -313,12 +76,11 @@ import { AppConfig } from "../config/configuration"; import { isCanaryIntent } from "../common/canary"; @ApiTags("intents") -@Controller("api/v1/intents") +@Controller({ path: "intents", version: "1" }) export class IntentsController { constructor( private readonly intentsService: IntentsService, private readonly solversService: SolversService, - @Optional() private readonly griefingService: SolverGriefingService | null, private readonly intentsGateway: IntentsGateway, private readonly tokensService: TokensService, private readonly routingService: RoutingService, @@ -575,6 +337,88 @@ export class IntentsController { return { intents, count: intents.length }; } + /** + * POST /api/v1/intents/batch-create + * + * Issue #429 — Atomic batch intent creation endpoint. + * Creates up to N intents atomically (all-or-nothing) with per-item validation errors. + */ + @Post("batch-create") + @UseGuards(UserThrottlerGuard, KillSwitchGuard) + @KillSwitchGate({ operation: "create" }) + @ApiOperation({ + summary: "Create multiple intents atomically", + description: + "Creates up to 50 intents in a single atomic (all-or-nothing) request. " + + "If any item fails validation or exceeds open intent limits, zero intents are created " + + "and per-item errors are reported.", + }) + @ApiOkResponse({ type: BatchCreateIntentsResponseDto }) + @ApiBadRequestResponse({ description: "Invalid request payload or empty batch" }) + @ApiUnprocessableEntityResponse({ description: "Per-item validation errors or limits exceeded" }) + async batchCreate(@Body() dto: BatchCreateIntentsDto) { + if (!dto.intents || !Array.isArray(dto.intents) || dto.intents.length === 0) { + throw new BadRequestException("Intents array must contain at least 1 item"); + } + + if (dto.intents.length > BATCH_CREATE_MAX_INTENTS) { + throw new BadRequestException( + `Batch size exceeds maximum allowed limit of ${BATCH_CREATE_MAX_INTENTS}`, + ); + } + + const now = Math.floor(Date.now() / 1000); + const items: NewIntentData[] = []; + + for (let i = 0; i < dto.intents.length; i++) { + const itemDto = dto.intents[i]; + const srcToken = await this.tokensService.resolveSrcTokenOrThrow( + itemDto.srcChain as SupportedChain, + itemDto.srcTokenAddress, + ); + const dstToken = await this.tokensService.resolveDstTokenOrThrow(itemDto.dstTokenContract); + + items.push({ + user: itemDto.user, + srcChain: itemDto.srcChain, + srcToken: { + address: itemDto.srcTokenAddress, + symbol: itemDto.srcTokenSymbol, + name: itemDto.srcTokenSymbol, + decimals: itemDto.srcTokenDecimals, + chain: itemDto.srcChain, + priceUSD: srcToken?.priceUSD, + }, + srcAmount: itemDto.srcAmount, + dstToken: { + contract: itemDto.dstTokenContract, + symbol: itemDto.dstTokenSymbol, + decimals: itemDto.dstTokenDecimals, + priceUSD: dstToken?.priceUSD, + }, + minDstAmount: itemDto.minDstAmount, + deadline: itemDto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[itemDto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), + }); + } + + const result = await this.intentsService.createBatch(items); + + if (result.errors.length > 0) { + throw new UnprocessableEntityException({ + statusCode: 422, + message: "Batch intent creation failed validation", + created: [], + errors: result.errors, + }); + } + + for (const intent of result.created) { + this.intentsGateway.broadcast({ type: "intent_created", intent }); + } + + return { created: result.created, errors: [] }; + } + @Post(":id/accept") @UseGuards(KillSwitchGuard) @KillSwitchGate({ operation: "accept" }) @@ -616,16 +460,6 @@ export class IntentsController { if (this.solversService.isSuspended(dto.solver)) { throw new ForbiddenException("Solver is suspended by an active guardian action"); } - // Anti-griefing enforcement (issue #453): check rolling unfilled-accept - // ratio before allowing the accept. Canary solvers are exempt — their - // fills are synthetic and must not inflate the enforcement counters. - if (!this.canary.has(dto.solver) && this.griefingService) { - const currentOpenAccepts = await this.intentsService.getAcceptedCountBySolver(dto.solver); - const griefingCheck = this.griefingService.checkAcceptAllowed(dto.solver, currentOpenAccepts, now); - if (!griefingCheck.allowed) { - throw new ForbiddenException(griefingCheck.reason ?? "Solver is blocked by anti-griefing controls"); - } - } // Canary intents pair only with canary solvers (issue #496) so synthetic // traffic never affects real solvers' stats or real users' fills. if (isCanaryIntent(intent, this.canary) !== this.canary.has(dto.solver)) { @@ -645,10 +479,6 @@ export class IntentsController { this.intentsService.appendAuditEntry(id, "accepted", dto.solver, "solver accepted", { deadline: updated.deadline, }); - // Anti-griefing: record the accept in the rolling window (issue #453). - if (!this.canary.has(dto.solver) && this.griefingService) { - this.griefingService.recordAccept(dto.solver, id, now); - } this.intentsGateway.broadcast({ type: "intent_accepted", intentId: id, @@ -764,6 +594,55 @@ export class IntentsController { return updated; } + /** + * Issue #569 — amend the terms of an open intent. + * + * Replaces `minDstAmount` and `deadline` on an intent that is still `open` + * and whose deadline has not passed. Both replacement values are covered by + * the creator's Ed25519 signature (see `buildAmendMessage`), so neither can + * change without the owner's consent. The write itself goes through + * `amendIfOpen`, whose state + deadline predicates make it race-free against + * a concurrent accept, and it deliberately does not bump `version` — + * widening the user's terms does not compete with solver writes. + */ + @Post(":id/amend") + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiForbiddenResponse({ description: "Unauthorized" }) + @ApiConflictResponse({ description: "Intent is not amendable" }) + async amend(@Param("id") id: string, @Body() dto: AmendIntentDto): Promise { + const intent = await this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + if (intent.user.toLowerCase() !== dto.user.toLowerCase()) { + throw new ForbiddenException("Unauthorized"); + } + + // The signature must cover the replacement values themselves, so a + // tampered minDstAmount or deadline fails verification before any write. + verifyStellarSignature( + dto.user, + buildAmendMessage(id, dto.user, dto.minDstAmount, dto.deadline), + dto.signature, + ); + + const amended = await this.intentsService.amendIfOpen(id, { + minDstAmount: dto.minDstAmount, + deadline: dto.deadline, + }); + if (!amended) { + const current = await this.intentsService.get(id); + throw new ConflictException(`Cannot amend intent in state: ${current?.state ?? "unknown"}`); + } + + this.intentsService.appendAuditEntry(id, "open", dto.user, "user amended", { + previousMinDstAmount: intent.minDstAmount, + minDstAmount: dto.minDstAmount, + previousDeadline: intent.deadline, + deadline: dto.deadline, + }); + + return amended; + } + /** * Issue #44 — document 429 on quote too, since it's under the global guard. * Issue #220 — routes are now computed via RoutingService and attached to each quote. diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index ca66ef85..54d144ff 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -214,7 +214,11 @@ describe("IntentsGateway heartbeat", () => { const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature })); - expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_ok" })); + // Ack the auth first, then immediately push the scoped eligibility + // snapshot (#436) — so the snapshot, not the ack, is the final frame of + // a successful handshake. + expect(client.send).toHaveBeenCalledWith(JSON.stringify({ type: "auth_ok" })); + expect(client.send).toHaveBeenLastCalledWith(expect.stringContaining('"type":"eligible_snapshot"')); await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature: "bad" })); expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index 7ebd6adf..7deb0d82 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -1,30 +1,4 @@ -import { Injectable, Logger, Optional } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { AppConfig } from "../config/configuration"; - -/** - * IntentsGateway — WebSocket gateway for real-time intent events. - * - * Stub that exposes `getSubscriberCount()` used by StatsService. - * Full WebSocket implementation delegates to IntentFeedService (issue #433). - * - * The stub is Injectable so it can be provided in test modules without - * requiring a real WS server. - */ -@Injectable() -export class IntentsGateway { - private readonly logger = new Logger(IntentsGateway.name); - - constructor( - @Optional() private readonly config?: ConfigService, - ) {} - - /** Returns the number of currently-connected WebSocket subscribers. */ - getSubscriberCount(): number { - // Delegates to the feed service in the real implementation. - // Returning 0 here is correct for the stub / test path. - return 0; -import { OnModuleDestroy, Optional, Inject } from "@nestjs/common"; +import { Inject, OnModuleDestroy, Optional } from "@nestjs/common"; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; import { WebSocket } from "ws"; import { IntentsService } from "./intents.service"; @@ -34,22 +8,40 @@ import { logger } from "../common/logger"; import { SUPPORTED_CHAINS, SupportedChain } from "./intents.types"; import { verifyStellarSignature, buildWsAuthMessage } from "../common/stellar-signature"; import { buildMatchPredicate, IntentCapabilityIndex, SolverMatchPredicate } from "./solver-intent-matcher"; -import { - WS_MAX_FILTER_CHAINS, - WS_MAX_SUBSCRIPTIONS_PER_CONNECTION, -} from "../config/limits.config"; +import { IntentFeedService } from "./feed/intent-feed.service"; +import type { BackplaneHealth } from "./backplane/backplane.types"; +import type { ReplayStore } from "./backplane/replay-store"; +import { REPLAY_STORE } from "./backplane/replay-store.token"; import { negotiateProtocol, resolveProtocol, - WS_CLOSE_UNSUPPORTED_PROTOCOL, WS_CLOSE_REASON_UNSUPPORTED, + WS_CLOSE_UNSUPPORTED_PROTOCOL, } from "./ws-protocol"; -import { REPLAY_STORE } from "./backplane/replay-store.token"; -import { ReplayStore, SequencedEvent } from "./backplane/replay-store"; -import { MemoryReplayStore } from "./backplane/memory-replay.store"; +import { + WS_MAX_FILTER_CHAINS, + WS_MAX_SUBSCRIPTIONS_PER_CONNECTION, +} from "../config/limits.config"; const HEARTBEAT_INTERVAL_MS = 30_000; +/** + * How many sequenced events to keep in the replay buffer. + * + * At typical broadcast volume (a few dozen events/minute in production), + * 500 events covers many minutes of missed events — more than enough to + * bridge a transient network blip or container restart without forcing a + * full snapshot re-fetch. Increasing this beyond ~1 000 starts to add + * non-trivial heap pressure for large event payloads; the current bound + * is a deliberate memory vs. reconnect-gap tradeoff. + */ +const REPLAY_BUFFER_SIZE = 500; + +export interface SequencedEvent { + seq: number; + type: string; + [key: string]: unknown; +} /** * Per-subscriber filter (issue #436). @@ -73,6 +65,49 @@ interface SubscriberFilter { subscriptionCount: number; } +/** + * Fixed-size ring buffer that retains the last `capacity` events so + * reconnecting clients can request a replay from a known sequence number. + */ +export class EventRingBuffer { + private readonly buf: SequencedEvent[] = []; + private readonly capacity: number; + + constructor(capacity = REPLAY_BUFFER_SIZE) { + this.capacity = capacity; + } + + push(event: SequencedEvent): void { + if (this.buf.length >= this.capacity) { + this.buf.shift(); + } + this.buf.push(event); + } + + /** + * Return all buffered events whose seq is strictly greater than `fromSeq`. + * Returns an empty array when `fromSeq` is older than the earliest buffered + * event (the caller should request a fresh snapshot instead). + */ + since(fromSeq: number): SequencedEvent[] { + return this.buf.filter((e) => e.seq > fromSeq); + } + + /** Lowest seq still in the buffer, or -1 when empty. */ + oldestSeq(): number { + return this.buf.length === 0 ? -1 : this.buf[0].seq; + } + + /** Highest seq in the buffer, or 0 when empty. */ + latestSeq(): number { + return this.buf.length === 0 ? 0 : this.buf[this.buf.length - 1].seq; + } + + size(): number { + return this.buf.length; + } +} + /** * Authentication / access-control decision (issue #49, updated #436) * ───────────────────────────────────────────────────────────────────── @@ -100,8 +135,9 @@ interface SubscriberFilter { * versions — giving the client a descriptive WS reason string. * * - vortex.v1 offered → echo "vortex.v1" - * - no protocol offered → echo "vortex.v1" (backward-compatible default) - * - unknown protocol → echo "" (empty); handleConnection closes 1002 + * - no protocol offered → echo "" (resolveProtocol treats "" as vortex.v1) + * - unknown protocol → echo the first offered token; handleConnection + * then closes with 1002 */ handleProtocols: negotiateProtocol, }) @@ -117,22 +153,44 @@ export class IntentsGateway // eslint-disable-next-line @typescript-eslint/no-explicit-any private heartbeatTimer: any; private nextSeq = 1; + /** True once graceful connection draining has begun (issue #511). */ + private draining = false; private readonly backplane: null | { publish: (event: Record) => void; subscribe: (handler: (event: Record) => void) => void; } = null; /** Ring buffer storing the last REPLAY_BUFFER_SIZE broadcast events. */ - private replayStore: ReplayStore; + private readonly ringBuffer = new EventRingBuffer(REPLAY_BUFFER_SIZE); + + /** + * Sequenced replay store (issue #457). + * + * Provided by the IntentsModule via the REPLAY_STORE token (memory or + * Redis, selected by WS_REPLAY_STORE). When absent — direct unit-harness + * construction — replay falls back to the in-process ring buffer above, + * which the broadcast path always keeps populated as well. + */ + private readonly replayStore: ReplayStore | null; constructor( private readonly intentsService: IntentsService, private readonly solversService: SolversService, private readonly intentIndex: IntentCapabilityIndex, @Optional() private readonly metricsService?: MetricsService, - @Optional() @Inject(REPLAY_STORE) replayStoreParam: ReplayStore | null = null, + /** + * Sequenced replay store (issue #457) — 5th positional parameter so the + * backplane test harnesses can inject a MemoryReplayStore directly. + */ + @Optional() @Inject(REPLAY_STORE) replayStoreParam?: ReplayStore | null, + /** + * SSE intent feed (issues #454, #492). Injected `@Optional()` so graphs + * that do not mount the feed — and the unit harnesses that construct this + * gateway directly — still run; it only supplies backplane health detail. + */ + @Optional() private readonly feed?: IntentFeedService, ) { - this.replayStore = replayStoreParam ?? new MemoryReplayStore({ maxCount: 500 }); + this.replayStore = replayStoreParam ?? null; this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); this.backplane = this.createBackplane(); if (this.backplane) { @@ -301,12 +359,18 @@ export class IntentsGateway } } - async handleConnection(client: WebSocket) { - // ── Protocol version check (issue #456) ──────────────────────────────── - // `client.protocol` is the negotiated subprotocol string from the HTTP - // upgrade handshake. Empty string means the client sent no - // Sec-WebSocket-Protocol header — we default to vortex.v1. - // Any other value that is not vortex.v1 is rejected with close code 1002. + handleConnection(client: WebSocket) { + // Graceful shutdown: refuse new clients once draining has begun (issue #511). + if (this.draining) { + client.close(1001, "Server draining"); + this.metricsService?.wsConnectionsRejected.inc({ reason: "draining" }); + return; + } + + // Subprotocol negotiation (issue #456): resolveProtocol treats "" as the + // backward-compatible default vortex.v1; any other non-empty string that + // is not vortex.v1 was echoed back by negotiateProtocol and is rejected + // here with a descriptive close reason. const protocolResult = resolveProtocol( (client as unknown as { protocol?: string }).protocol ?? "", ); @@ -343,7 +407,7 @@ export class IntentsGateway ); }); - const currentSeq = await this.replayStore.latestSeq(); + const currentSeq = this.nextSeq - 1; client.send( JSON.stringify({ @@ -542,6 +606,13 @@ export class IntentsGateway /** * Process a `{ type: "replay", fromSeq: number }` message. + * + * Events are read from the injected replay store when one is wired + * (issue #457 — memory or Redis, survives restarts and spans replicas), + * otherwise from the in-process ring buffer. Before the events are sent, + * the requesting connection's subscription filter (chains or solver + * capability predicate) is applied so replay honours the same + * server-side scoping as live delivery. */ private async handleReplay(client: WebSocket, msg: Record): Promise { const fromSeq = typeof msg.fromSeq === "number" ? msg.fromSeq : null; @@ -552,23 +623,47 @@ export class IntentsGateway if (client.readyState !== WebSocket.OPEN) return; - const result = await this.replayStore.since(fromSeq); + let events: SequencedEvent[]; + if (this.replayStore) { + const result = await this.replayStore.since(fromSeq); + if (result.tooOld) { + const oldest = await this.replayStore.oldestSeq(); + client.send(JSON.stringify({ type: "replay_too_old", fromSeq, oldestAvailableSeq: oldest })); + logger.debug(`ws replay_too_old: fromSeq=${fromSeq} oldestAvailable=${oldest}`); + return; + } + events = result.events; + } else { + const oldest = this.ringBuffer.oldestSeq(); - if (result.tooOld) { - const oldest = await this.replayStore.oldestSeq(); - client.send(JSON.stringify({ type: "replay_too_old", fromSeq, oldestAvailableSeq: oldest })); - logger.debug(`ws replay_too_old: fromSeq=${fromSeq} oldestAvailable=${oldest}`); - return; - } + if (oldest !== -1 && fromSeq < oldest - 1) { + client.send( + JSON.stringify({ + type: "replay_too_old", + fromSeq, + oldestAvailableSeq: oldest, + }), + ); + logger.debug(`ws replay_too_old: fromSeq=${fromSeq} oldestAvailable=${oldest}`); + return; + } - const events = result.events; + events = this.ringBuffer.since(fromSeq); + } - client.send(JSON.stringify({ type: "replay_start", fromSeq, count: events.length })); + client.send( + JSON.stringify({ + type: "replay_start", + fromSeq, + count: events.length, + }), + ); const filter = this.subscribers.get(client); for (const event of events) { if (client.readyState !== WebSocket.OPEN) break; - // Apply server-side filter (same logic as deliverToMatchingSubscribers but for one client) + // Apply server-side filter (same logic as deliverToMatchingSubscribers + // but for this one client). if (filter) { if (!filter.wantAll) { if (filter.solver !== null) { @@ -589,8 +684,14 @@ export class IntentsGateway } if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ type: "replay_end", count: events.length })); + client.send( + JSON.stringify({ + type: "replay_end", + count: events.length, + }), + ); } + logger.debug(`ws replay complete: fromSeq=${fromSeq} count=${events.length}`); } @@ -751,8 +852,17 @@ export class IntentsGateway // eligible-intents call sees fresh state. this.updateIndexForEvent(event); - // Push into replay store before sending. - await this.replayStore.append(sequencedEvent); + // Push into replay buffer before sending. + this.ringBuffer.push(sequencedEvent); + // Mirror into the injected replay store (issue #457) so reconnecting + // clients can replay across restarts/replicas. + if (this.replayStore) { + try { + await this.replayStore.append(sequencedEvent); + } catch (err) { + logger.warn(`replay store append failed: ${String(err)}`); + } + } logger.debug(`ws broadcast type=${event.type} seq=${seq} subscribers=${this.subscribers.size}`); @@ -807,6 +917,25 @@ export class IntentsGateway return this.subscribers.size; } + /** + * Backplane health for /health (issues #454, #492). + * + * Delegates to the intent feed when it is wired — the feed owns the real + * backplane link (and its Redis connection state). Without a feed, fall + * back to this gateway's own view: a memory backplane is a single replica + * and is healthy by construction; the gateway's redis mirror reports its + * sequenced high-water mark. + */ + backplaneHealth(): BackplaneHealth { + if (this.feed) return this.feed.backplaneHealth(); + return { + mode: this.backplane ? "redis" : "memory", + status: "ok", + lastSeq: this.nextSeq - 1, + pendingPublishes: 0, + }; + } + /** Returns the current number of active WebSocket subscribers. */ get subscriberCount(): number { return this.subscribers.size; @@ -830,11 +959,79 @@ export class IntentsGateway } } - onModuleDestroy() { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + /** + * Graceful connection draining (issue #511): + * 1. Set the draining flag so `handleConnection` refuses new clients + * 2. Send every connected client a `server_draining` event carrying the + * resume sequence so it can reconnect with replay + * 3. Close connections in batches with jittered delays to avoid a + * reconnect stampede against the replacement pod + * + * Kubernetes terminationGracePeriodSeconds should be at least + * WS_DRAIN_TIMEOUT_MS + 5s. + */ + async startDraining(): Promise { + if (this.draining) return; + + this.draining = true; + const drainTimeoutMs = parseInt(process.env.WS_DRAIN_TIMEOUT_MS ?? "25000", 10); + const currentSeq = this.nextSeq - 1; + const clientCount = this.subscribers.size; + + logger.warn(`ws draining started: ${clientCount} clients, timeout=${drainTimeoutMs}ms`); + + const drainMessage = JSON.stringify({ + type: "server_draining", + resumeFrom: currentSeq, + reconnectAfterMs: Math.floor(1000 + Math.random() * 4000), + reason: "graceful_shutdown", + }); + for (const [client] of this.subscribers) { - client.close(1001, "Server shutting down"); - this.removeSubscriber(client); + if (client.readyState === WebSocket.OPEN) { + try { + client.send(drainMessage); + } catch { + /* best effort — the connection is going away anyway */ + } + } + } + + if (clientCount === 0) { + logger.warn("ws draining complete: no clients connected"); + return; + } + + const batchSize = Math.max(10, Math.ceil(clientCount / 10)); + const clients = Array.from(this.subscribers.keys()); + const batchDelayMs = Math.floor(drainTimeoutMs / Math.ceil(clientCount / batchSize)); + + for (let i = 0; i < clients.length; i += batchSize) { + const batch = clients.slice(i, i + batchSize); + if (i > 0) await new Promise((r) => setTimeout(r, batchDelayMs)); + + for (const client of batch) { + if (client.readyState === WebSocket.OPEN) { + client.close(1001, "Server draining"); + } + this.removeSubscriber(client); + } + + logger.info( + `ws drain progress: ${Math.min(i + batchSize, clientCount)}/${clientCount} closed`, + ); } + + logger.warn("ws draining complete: all clients closed"); + } + + /** True while the gateway refuses new connections during shutdown (issue #511). */ + isDraining(): boolean { + return this.draining; + } + + async onModuleDestroy() { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + await this.startDraining(); } } diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 4ea94aa0..9bbc95d9 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -1,38 +1,3 @@ -import { Module } from "@nestjs/common"; -import { IntentsService } from "./intents.service"; -import { IntentsController } from "./intents.controller"; -import { IntentsGateway } from "./intents.gateway"; -import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; -import { PrismaIntentsRepository } from "./prisma-intents.repository"; -import { DualWriteIntentsRepository } from "./dual-write-intents.repository"; -import { PrismaService } from "../prisma/prisma.service"; - -/** - * IntentsModule wires the intents feature slice. - * - * The active repository adapter is selected at startup via INTENTS_STORE: - * memory — InMemoryIntentsRepository (default, dev/test) - * dual — DualWriteIntentsRepository (migration phase) - * postgres — PrismaIntentsRepository (production) - * - * IntentsGateway is exported so StatsModule can inject it for subscriber counts. - */ -@Module({ - controllers: [IntentsController], - providers: [ - { - provide: INTENTS_REPOSITORY, - inject: [PrismaService], - useFactory: (prisma: PrismaService) => { - const store = process.env.INTENTS_STORE ?? process.env.INTENTS_PERSISTENCE ?? "memory"; - if (store === "postgres") { - return new PrismaIntentsRepository(prisma); - } - if (store === "dual") { - const primary = new InMemoryIntentsRepository({ seed: false }); - const secondary = new PrismaIntentsRepository(prisma); - return new DualWriteIntentsRepository(primary, secondary); - } import { Module, forwardRef } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { IntentsService } from "./intents.service"; @@ -85,28 +50,20 @@ import { RedisReplayStore } from "./backplane/redis-replay.store"; return new InMemoryIntentsRepository(); }, }, - IntentsService, - IntentsGateway, - ], - exports: [IntentsService, IntentsGateway, INTENTS_REPOSITORY], - IntentCapabilityIndex, - IntentsGateway, - IntentsSweeperService, - IntentsMaintenanceJobs, - // Note: EventIngestionService is provided by SorobanModule (imported above) - // and exported from there — no re-declaration needed here. + // Sequenced WS replay store (issue #457) — memory by default, Redis when + // WS_REPLAY_STORE=redis. Injected into IntentsGateway via REPLAY_STORE. { provide: REPLAY_STORE, useFactory: () => { - const store = (process.env.WS_REPLAY_STORE ?? 'memory').toLowerCase(); - const maxCount = parseInt(process.env.WS_REPLAY_MAX_COUNT ?? '500', 10); + const store = (process.env.WS_REPLAY_STORE ?? "memory").toLowerCase(); + const maxCount = parseInt(process.env.WS_REPLAY_MAX_COUNT ?? "500", 10); const maxAgeMs = process.env.WS_REPLAY_MAX_AGE_MS ? parseInt(process.env.WS_REPLAY_MAX_AGE_MS, 10) : undefined; - if (store === 'redis') { + if (store === "redis") { return new RedisReplayStore({ - redisUrl: process.env.REDIS_URL ?? 'redis://localhost:6379', - streamKey: 'vortex:intents:replay', + redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379", + streamKey: "vortex:intents:replay", maxCount, maxAgeMs, }); @@ -114,6 +71,13 @@ import { RedisReplayStore } from "./backplane/redis-replay.store"; return new MemoryReplayStore({ maxCount, maxAgeMs }); }, }, + IntentsService, + IntentCapabilityIndex, + IntentsGateway, + IntentsSweeperService, + IntentsMaintenanceJobs, + // Note: EventIngestionService is provided by SorobanModule (imported above) + // and exported from there — no re-declaration needed here. ], exports: [IntentsService, IntentsGateway, IntentCapabilityIndex, REPLAY_STORE], }) diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index 1e31f7a5..b91b0803 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -1,24 +1,30 @@ import { Injectable } from "@nestjs/common"; import { v4 as uuidv4 } from "uuid"; -import { Intent, IntentState } from "./intents.types"; +import { Intent, IntentState, SupportedChain } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; /** * NestJS injection token for the intents repository. * + * Use this token instead of a concrete class so any module can swap + * InMemoryIntentsRepository for a Prisma-backed adapter without touching + * IntentsService. + * * @example * \@Inject(INTENTS_REPOSITORY) private readonly repo: IIntentsRepository */ export const INTENTS_REPOSITORY = Symbol("INTENTS_REPOSITORY"); -// ─── Optimistic-concurrency types (#404) ───────────────────────────────────── +type MaybePromise = T | Promise; /** - * Returned by mutations when the caller's `expectedVersion` does not match - * the row's actual version. The caller should re-read the row, reconcile, - * and retry. + * Returned by a mutation whose `expectedVersion` no longer matches the stored + * record (issue #405). Carries the version actually found so callers can + * decide whether to re-read and retry or surface a conflict to the client. */ export class VersionConflict { + readonly kind = "version_conflict" as const; + constructor( readonly intentId: string, readonly expectedVersion: number, @@ -26,110 +32,190 @@ export class VersionConflict { ) {} } -export function isVersionConflict(result: unknown): result is VersionConflict { - return result instanceof VersionConflict; +/** Type guard for {@link VersionConflict}. */ +export function isVersionConflict(value: unknown): value is VersionConflict { + return value instanceof VersionConflict; } /** - * Union of successful intent result and version conflict. - * All guarded mutation methods return this type. + * Result of a mutation: the updated intent, `null` when the intent does not + * exist or its state guard failed, or a {@link VersionConflict} when an + * `expectedVersion` was supplied and did not match. */ -export type MutationResult = Intent | VersionConflict | null; +export type MutationResult = Intent | null | VersionConflict; -// ─── Idempotency (#404) ─────────────────────────────────────────────────────── +/** Fields a generic `update()` may change — identity and version are managed by the repository. */ +export type IntentPatch = Omit, "intentId" | "version" | "createdAt">; +/** Result of {@link IIntentsRepository.createIdempotent}. */ export interface IdempotentCreateResult { intent: Intent; - /** true when a new row was created; false when the key already existed (replay). */ + /** false when an unexpired intent already held the idempotency key. */ created: boolean; } -// ─── Patch type ─────────────────────────────────────────────────────────────── - -export type IntentPatch = Partial>; +/** + * Filter / sort / pagination parameters for advanced intent search (issue #440). + * + * All fields are optional; when none are present the search returns every + * intent sorted by `createdAt` descending — identical to the pre-existing + * default behaviour. + */ +export interface IntentSearchQuery { + state?: IntentState; + user?: string; + chain?: SupportedChain; + /** Minimum USD value at creation (inclusive). */ + minAmountUsd?: number; + /** Maximum USD value at creation (inclusive). */ + maxAmountUsd?: number; + /** Minimum creation time, unix epoch seconds (inclusive). */ + createdFrom?: number; + /** Maximum creation time, unix epoch seconds (inclusive). */ + createdTo?: number; + /** Source token symbol (case-insensitive). */ + srcToken?: string; + /** Destination token symbol (case-insensitive). */ + dstToken?: string; + /** Solver address that accepted/filled the intent. */ + solver?: string; + /** + * Sort dimension and direction: `created` | `deadline` | `usd`, optionally + * with an `:asc` / `:desc` suffix. Defaults to `created:desc`. + */ + sort?: string; + limit?: number; + offset?: number; +} -// ─── Repository interface ───────────────────────────────────────────────────── +/** A page of search results plus the total number of matching intents. */ +export interface IntentSearchResult { + intents: Intent[]; + total: number; +} +/** + * Storage contract for intent records. + * + * Every mutation increments `version` by exactly one, and every mutation + * accepts an expected version so concurrent writers (HTTP handlers, the + * sweeper, event ingestion, the deposit verifier) can never silently + * overwrite one another (issue #405). Implementations must apply the state + * guard, the version check, and the write as a single atomic step — for SQL + * that means one `UPDATE … WHERE … RETURNING *` statement. + * + * All methods are synchronous for the in-memory adapter and return Promises + * for the Prisma adapter — callers always `await` so both shapes work. The + * shared contract suite in `intents-repository.contract.ts` runs against + * every implementation. + */ export interface IIntentsRepository { - save(intent: Intent): Intent | Promise; - findById(id: string): Intent | undefined | Promise; - findAll(): Intent[] | Promise; - findByState(state: IntentState): Intent[] | Promise; - findByUser(user: string): Intent[] | Promise; - findManyByIds(ids: string[]): Intent[] | Promise; - countAcceptedBySolver(solver: string): number | Promise; - countActiveByUser(user: string): number | Promise; + /** + * Persist a fully-formed intent record exactly as given (including its + * `version`) and return it. Used for creation and for mirroring rows between + * stores; it is not a mutation path — use {@link update} to change a record. + */ + save(intent: Intent): MaybePromise; /** - * Idempotent create: inserts only when no row exists for `idempotencyKey` - * with `createdAt >= minCreatedAt`. Returns the existing row on replay. + * Insert `intent` unless another intent created at or after + * `minCreatedAt` already holds `idempotencyKey`, in which case that intent + * is returned instead. Must be atomic across replicas (a unique index on the + * key in SQL). A key held by an older intent is released and reused. */ createIdempotent( intent: Intent, idempotencyKey: string, minCreatedAt: number, - ): IdempotentCreateResult | Promise; + ): MaybePromise; - findByIdempotencyKey( - key: string, - minCreatedAt: number, - ): Intent | undefined | Promise; + /** Find the unexpired intent created with `idempotencyKey`, if any. */ + findByIdempotencyKey(idempotencyKey: string, minCreatedAt: number): MaybePromise; + + /** + * Find an intent by its unique intentId. + * Returns `undefined` when no matching record exists. + */ + findById(id: string): MaybePromise; + + /** Fetch several intents in one call; unknown IDs are omitted. */ + findManyByIds(ids: string[]): MaybePromise; /** - * Apply a partial patch. Returns VersionConflict on stale write, null when - * the intent is not found. - * Atomically replace an open intent's minimum output and deadline while its - * current deadline is still in the future. Returns null when the intent is - * missing, no longer open, or already expired. + * Return all intent records sorted by createdAt descending. + */ + findAll(): MaybePromise; + + /** + * Return all intents matching the given state, sorted by createdAt descending. + */ + findByState(state: IntentState): MaybePromise; + + /** + * Return all intents belonging to the given user (case-insensitive address match). + */ + findByUser(user: string): MaybePromise; + + /** Number of intents currently `accepted` by `solver`. */ + countAcceptedBySolver(solver: string): MaybePromise; + + /** Number of `open` or `accepted` intents owned by `user` (case-insensitive). */ + countActiveByUser(user: string): MaybePromise; + + /** + * Apply `patch` only if the stored version equals `expectedVersion`: + * UPDATE intents SET …patch, version = version + 1 + * WHERE intent_id = $1 AND version = $2 RETURNING * + * Returns `null` when the intent does not exist. + */ + update(id: string, patch: IntentPatch, expectedVersion?: number): MaybePromise; + + /** + * Amend the user-adjustable fields of an `open` intent while its deadline is + * still in the future (issue #569): only `minDstAmount` and `deadline` may + * change, and a new deadline must also lie in the future. Returns `null` + * when the intent does not exist, is not open, or either deadline check + * fails; the repository does not bump `version` (the amend is a widening of + * terms, not a competing solver write). */ amendIfOpen( id: string, patch: Pick, now?: number, - ): Intent | null | Promise; + ): MaybePromise; /** * Remove a stored intent. Used only for in-memory retention sweeps for stale - * terminal-state records; Prisma-backed stores ignore this call by design. + * terminal-state records. */ - update( - id: string, - patch: IntentPatch, - expectedVersion: number, - ): MutationResult | Promise; - - delete(id: string): boolean | Promise; - - // ── Atomic state transitions ────────────────────────────────────────────── + delete(id: string): MaybePromise; + /** + * Atomically transition an intent from `open` → `accepted` while its + * deadline is still in the future (issue #473): + * UPDATE intents SET state='accepted', solver=$2, deadline=$3, version=version+1 + * WHERE intent_id=$1 AND state='open' AND deadline > $now [AND version=$v] RETURNING * + * Returns `null` when the intent is not found, already taken, or past its + * deadline (the sweeper wins that race). `now` defaults to the current time. + * + * Lock ordering (issue #473): callers holding a per-solver advisory lock + * must acquire it BEFORE invoking this method; this method itself only + * touches the single intent row so no lock inversion is possible. + */ acceptIfOpen( id: string, solver: string, newDeadline: number, now?: number, expectedVersion?: number, - ): MutationResult | Promise; - - acceptIfOpenWithinExposure( - id: string, - solver: string, - newDeadline: number, - now: number, - candidateExposureUsdMicros: bigint, - maxExposureUsdMicros: bigint, - ): Promise<{ intent: Intent | null; exposureExceeded: boolean }> | { intent: Intent | null; exposureExceeded: boolean }; + ): MaybePromise; /** * Atomically transition an intent from `accepted` → `filled` only if it is * currently accepted by the specified solver AND the fill window has not - * elapsed. Mirrors the DB pattern: - * UPDATE intents SET state='filled', ...patch - * WHERE intent_id=$1 AND state='accepted' AND solver=$2 AND deadline > $3 - * RETURNING * - * Returns the updated intent on success, `null` on any guard failure. - * The `minDstAmount` invariant (fill >= minDst) is enforced by the - * controller/service layer with bigint comparison before this write; the - * state+deadline predicates here make the write itself race-free. + * elapsed (issue #473). The `minDstAmount` invariant is enforced by the + * controller before this write; the state + deadline predicates make the + * write itself race-free. */ fillIfAccepted( id: string, @@ -137,42 +223,63 @@ export interface IIntentsRepository { patch: Pick, "filledAt" | "fillAmount" | "feeAmount" | "txHash">, now?: number, expectedVersion?: number, - ): MutationResult | Promise; - - cancelIfOpen(id: string, expectedVersion?: number): MutationResult | Promise; - - expireIfOpen(id: string, expectedVersion?: number): MutationResult | Promise; + ): MaybePromise; + /** + * Atomically push an accepted intent's deadline out to `newDeadline`, only + * while it is still `accepted` and its deadline is earlier (issue #477): + * UPDATE intents SET deadline=$2, version=version+1 + * WHERE intent_id=$1 AND state='accepted' AND deadline < $2 [AND version=$v] RETURNING * + * While an emergency pause covers `fill`, the sweeper extends windows instead + * of slashing. The `deadline < $2` guard makes repeat calls no-ops and never + * shortens a window; a concurrent fill or slash wins via the state predicate. + */ extendDeadlineIfAccepted( id: string, newDeadline: number, expectedVersion?: number, - ): MutationResult | Promise; + ): MaybePromise; + + /** Atomically transition an intent from `open` → `cancelled`. */ + cancelIfOpen(id: string, expectedVersion?: number): MaybePromise; + + /** + * Atomically transition an intent from `open` → `expired`. Guards the + * sweeper's expiry pass against a concurrent user cancel() or solver accept(). + */ + expireIfOpen(id: string, expectedVersion?: number): MaybePromise; + /** + * Atomically transition an intent from `accepted` → `slashed`. Guards the + * sweeper's slashing pass against a concurrent solver fill(). + */ slashIfAccepted( id: string, patch: { slashedAt: number; slashReason: string }, expectedVersion?: number, - ): MutationResult | Promise; - - /** - * Version-guarded upsert used by the dual-write mirror. - * Only saves when the incoming version is >= the stored version. - */ - saveIfNewer?(intent: Intent): Promise; + ): MaybePromise; } -// ─── In-memory implementation ───────────────────────────────────────────────── +/** Options for {@link InMemoryIntentsRepository}. */ +export interface InMemoryIntentsRepositoryOptions { + /** Seed demo intents on construction (default true). Disabled in `dual` mode. */ + seed?: boolean; +} +/** + * In-memory implementation of IIntentsRepository. + * + * Stores intents in a plain `Map`. Every method runs synchronously, so each + * check-and-write is atomic within the Node.js event loop — this is the + * reference behaviour the Prisma adapter reproduces with single SQL statements. + */ @Injectable() export class InMemoryIntentsRepository implements IIntentsRepository { private readonly store = new Map(); - /** key → intentId (idempotency replay cache) */ private readonly idempotencyKeys = new Map(); - constructor(options?: { seed?: boolean }) { - const shouldSeed = options?.seed !== false; - if (shouldSeed) this.seed(); + constructor(options: InMemoryIntentsRepositoryOptions = {}) { + if (options.seed ?? true) this.seed(); } save(intent: Intent): Intent { @@ -180,10 +287,35 @@ export class InMemoryIntentsRepository implements IIntentsRepository { return intent; } + createIdempotent(intent: Intent, idempotencyKey: string, minCreatedAt: number): IdempotentCreateResult { + const existing = this.findByIdempotencyKey(idempotencyKey, minCreatedAt); + if (existing) return { intent: existing, created: false }; + this.store.set(intent.intentId, intent); + this.idempotencyKeys.set(idempotencyKey, intent.intentId); + return { intent, created: true }; + } + + findByIdempotencyKey(idempotencyKey: string, minCreatedAt: number): Intent | undefined { + const intentId = this.idempotencyKeys.get(idempotencyKey); + if (!intentId) return undefined; + const intent = this.store.get(intentId); + if (!intent || intent.createdAt < minCreatedAt) { + this.idempotencyKeys.delete(idempotencyKey); + return undefined; + } + return intent; + } + findById(id: string): Intent | undefined { return this.store.get(id); } + findManyByIds(ids: string[]): Intent[] { + return [...new Set(ids)] + .map((id) => this.store.get(id)) + .filter((intent): intent is Intent => intent !== undefined); + } + findAll(): Intent[] { return [...this.store.values()].sort((a, b) => b.createdAt - a.createdAt); } @@ -193,61 +325,94 @@ export class InMemoryIntentsRepository implements IIntentsRepository { } findByUser(user: string): Intent[] { - const lower = user.toLowerCase(); - return this.findAll().filter((i) => i.user.toLowerCase() === lower); + return this.findAll().filter((i) => i.user.toLowerCase() === user.toLowerCase()); } - findManyByIds(ids: string[]): Intent[] { - const unique = [...new Set(ids)]; - return unique.flatMap((id) => { - const i = this.store.get(id); - return i ? [i] : []; + /** + * Advanced search with filtering, sorting and pagination (issue #440). + * Mirrors the SQL adapter's semantics in memory so both stores satisfy + * `intents-search.spec.ts`. + */ + search(query: IntentSearchQuery): IntentSearchResult { + let results = this.findAll(); + + if (query.state !== undefined) results = results.filter((i) => i.state === query.state); + if (query.user !== undefined) { + const needle = query.user.toLowerCase(); + results = results.filter((i) => i.user.toLowerCase() === needle); + } + if (query.chain !== undefined) results = results.filter((i) => i.srcChain === query.chain); + if (query.solver !== undefined) { + const needle = query.solver.toLowerCase(); + results = results.filter((i) => i.solver !== undefined && i.solver.toLowerCase() === needle); + } + if (query.minAmountUsd !== undefined) { + results = results.filter( + (i) => i.usdValueAtCreate !== undefined && i.usdValueAtCreate >= query.minAmountUsd!, + ); + } + if (query.maxAmountUsd !== undefined) { + results = results.filter( + (i) => i.usdValueAtCreate !== undefined && i.usdValueAtCreate <= query.maxAmountUsd!, + ); + } + if (query.createdFrom !== undefined) results = results.filter((i) => i.createdAt >= query.createdFrom!); + if (query.createdTo !== undefined) results = results.filter((i) => i.createdAt <= query.createdTo!); + if (query.srcToken !== undefined) { + const needle = query.srcToken.toLowerCase(); + results = results.filter((i) => i.srcToken.symbol.toLowerCase() === needle); + } + if (query.dstToken !== undefined) { + const needle = query.dstToken.toLowerCase(); + results = results.filter((i) => i.dstToken.symbol.toLowerCase() === needle); + } + + // Sorting — default createdAt desc (preserves pre-existing behaviour). + const [dimension, direction] = (query.sort ?? "created:desc").split(":"); + const dir = direction === "asc" ? 1 : -1; + results.sort((a, b) => { + switch (dimension) { + case "deadline": + return (a.deadline - b.deadline) * dir; + case "usd": { + const av = a.usdValueAtCreate ?? 0; + const bv = b.usdValueAtCreate ?? 0; + return (av - bv) * dir; + } + case "created": + default: + return (a.createdAt - b.createdAt) * dir; + } }); + + const total = results.length; + const offset = query.offset ?? 0; + const limit = query.limit ?? 20; + const page = results.slice(offset, offset + limit); + return { intents: page, total }; } countAcceptedBySolver(solver: string): number { - const lower = solver.toLowerCase(); - return [...this.store.values()].filter( - (i) => i.state === "accepted" && i.solver?.toLowerCase() === lower, - ).length; + let count = 0; + for (const intent of this.store.values()) { + if (intent.state === "accepted" && intent.solver === solver) count++; + } + return count; } countActiveByUser(user: string): number { - const lower = user.toLowerCase(); - return [...this.store.values()].filter( - (i) => (i.state === "open" || i.state === "accepted") && i.user.toLowerCase() === lower, - ).length; - } - - createIdempotent( - intent: Intent, - idempotencyKey: string, - minCreatedAt: number, - ): IdempotentCreateResult { - const existing = this.findByIdempotencyKey(idempotencyKey, minCreatedAt); - if (existing) return { intent: existing, created: false }; - this.save(intent); - this.idempotencyKeys.set(idempotencyKey, intent.intentId); - return { intent, created: true }; - } - - findByIdempotencyKey(key: string, minCreatedAt: number): Intent | undefined { - const id = this.idempotencyKeys.get(key); - if (!id) return undefined; - const intent = this.store.get(id); - if (!intent || intent.createdAt < minCreatedAt) return undefined; - return intent; + const needle = user.toLowerCase(); + let count = 0; + for (const intent of this.store.values()) { + if ((intent.state === "open" || intent.state === "accepted") && intent.user.toLowerCase() === needle) { + count++; + } + } + return count; } - update(id: string, patch: IntentPatch, expectedVersion: number): MutationResult { - const existing = this.store.get(id); - if (!existing) return null; - if (existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { ...existing, ...patch, version: existing.version + 1 }; - this.store.set(id, updated); - return updated; + update(id: string, patch: IntentPatch, expectedVersion?: number): MutationResult { + return this.mutate(id, expectedVersion, () => true, (existing) => ({ ...existing, ...patch })); } amendIfOpen( @@ -268,16 +433,6 @@ export class InMemoryIntentsRepository implements IIntentsRepository { return this.store.delete(id); } - saveIfNewer(intent: Intent): Promise { - const existing = this.store.get(intent.intentId); - if (!existing || intent.version >= existing.version) { - this.store.set(intent.intentId, intent); - } - return Promise.resolve(this.store.get(intent.intentId)!); - } - - // ── Atomic transitions ──────────────────────────────────────────────────── - acceptIfOpen( id: string, solver: string, @@ -285,55 +440,13 @@ export class InMemoryIntentsRepository implements IIntentsRepository { now?: number, expectedVersion?: number, ): MutationResult { - const existing = this.store.get(id); - if (!existing || existing.state !== "open") return null; const nowSec = now ?? Math.floor(Date.now() / 1000); - if (existing.deadline <= nowSec) return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { - ...existing, - state: "accepted", - solver, - deadline: newDeadline, - version: existing.version + 1, - }; - this.store.set(id, updated); - return updated; - } - - acceptIfOpenWithinExposure( - id: string, - solver: string, - newDeadline: number, - now: number, - candidateExposureUsdMicros: bigint, - maxExposureUsdMicros: bigint, - ): { intent: Intent | null; exposureExceeded: boolean } { - const existing = this.store.get(id); - if (!existing || existing.state !== "open" || existing.deadline <= now) { - return { intent: null, exposureExceeded: false }; - } - const lower = solver.toLowerCase(); - let acceptedExposure = 0n; - for (const intent of this.store.values()) { - if (intent.state === "accepted" && intent.solver?.toLowerCase() === lower) { - acceptedExposure += intentExposureUsdMicros(intent, now); - } - } - if (acceptedExposure + candidateExposureUsdMicros > maxExposureUsdMicros) { - return { intent: null, exposureExceeded: true }; - } - const updated: Intent = { - ...existing, - state: "accepted", - solver, - deadline: newDeadline, - version: existing.version + 1, - }; - this.store.set(id, updated); - return { intent: updated, exposureExceeded: false }; + return this.mutate( + id, + expectedVersion, + (i) => i.state === "open" && i.deadline > nowSec, + (i) => ({ ...i, state: "accepted", solver, deadline: newDeadline }), + ); } fillIfAccepted( @@ -343,68 +456,70 @@ export class InMemoryIntentsRepository implements IIntentsRepository { now?: number, expectedVersion?: number, ): MutationResult { - const existing = this.store.get(id); - if (!existing || existing.state !== "accepted" || existing.solver !== solver) return null; const nowSec = now ?? Math.floor(Date.now() / 1000); - if (existing.deadline <= nowSec) return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { ...existing, ...patch, state: "filled", version: existing.version + 1 }; - this.store.set(id, updated); - return updated; + return this.mutate( + id, + expectedVersion, + (i) => i.state === "accepted" && i.solver === solver && i.deadline > nowSec, + (i) => ({ ...i, ...patch, state: "filled" }), + ); } - cancelIfOpen(id: string, expectedVersion?: number): MutationResult { - const existing = this.store.get(id); - if (!existing || existing.state !== "open") return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { ...existing, state: "cancelled", version: existing.version + 1 }; - this.store.set(id, updated); - return updated; + extendDeadlineIfAccepted(id: string, newDeadline: number, expectedVersion?: number): MutationResult { + return this.mutate( + id, + expectedVersion, + (i) => i.state === "accepted" && i.deadline < newDeadline, + (i) => ({ ...i, deadline: newDeadline }), + ); } - expireIfOpen(id: string, expectedVersion?: number): MutationResult { - const existing = this.store.get(id); - if (!existing || existing.state !== "open") return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { ...existing, state: "expired", version: existing.version + 1 }; - this.store.set(id, updated); - return updated; + cancelIfOpen(id: string, expectedVersion?: number): MutationResult { + return this.mutate(id, expectedVersion, (i) => i.state === "open", (i) => ({ ...i, state: "cancelled" })); } - extendDeadlineIfAccepted(id: string, newDeadline: number, expectedVersion?: number): MutationResult { - const existing = this.store.get(id); - if (!existing || existing.state !== "accepted") return null; - if (existing.deadline >= newDeadline) return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); - } - const updated: Intent = { ...existing, deadline: newDeadline, version: existing.version + 1 }; - this.store.set(id, updated); - return updated; + expireIfOpen(id: string, expectedVersion?: number): MutationResult { + return this.mutate(id, expectedVersion, (i) => i.state === "open", (i) => ({ ...i, state: "expired" })); } slashIfAccepted( id: string, patch: { slashedAt: number; slashReason: string }, expectedVersion?: number, + ): MutationResult { + return this.mutate( + id, + expectedVersion, + (i) => i.state === "accepted", + (i) => ({ ...i, ...patch, state: "slashed" }), + ); + } + + /** + * Shared check-and-write. Mirrors the SQL adapter's classification: a + * version mismatch is reported as a conflict before the state guard is + * consulted, exactly as `WHERE version = $v AND state = …` followed by a + * classifying re-read behaves. + */ + private mutate( + id: string, + expectedVersion: number | undefined, + guard: (intent: Intent) => boolean, + apply: (intent: Intent) => Intent, ): MutationResult { const existing = this.store.get(id); - if (!existing || existing.state !== "accepted") return null; - if (expectedVersion !== undefined && existing.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, existing.version); + if (!existing) return null; + const existingVersion = existing.version ?? 0; + if (expectedVersion !== undefined && existingVersion !== expectedVersion) { + return new VersionConflict(id, expectedVersion, existingVersion); } - const updated: Intent = { ...existing, ...patch, state: "slashed", version: existing.version + 1 }; + if (!guard(existing)) return null; + const updated: Intent = { ...apply(existing), intentId: id, version: existingVersion + 1 }; this.store.set(id, updated); return updated; } - // ── Seed ──────────────────────────────────────────────────────────────────── + // ── seed ──────────────────────────────────────────────────────────────────── seed(): void { const now = Math.floor(Date.now() / 1000); @@ -415,6 +530,7 @@ export class InMemoryIntentsRepository implements IIntentsRepository { createdAt: now - Math.floor(Math.random() * 600), version: 0, srcVerified: true, + srcVerification: { status: "skipped", checkedAt: now, detail: "demo seed data" }, }; this.store.set(intent.intentId, intent); } diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index e69de29b..36588b18 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -0,0 +1,555 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; +import { AppConfig, CHAIN_FILL_WINDOW_DEFAULTS, DEFAULT_FILL_WINDOW_SECONDS } from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { IntentsService } from "./intents.service"; +import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; +import { PrismaService } from "../prisma/prisma.service"; +import { ProtocolParamsService } from "../governance/params.service"; + +const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +function fakeConfig(overrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}) { + const values: Record = { + onchainIntentsEnabled: overrides.onchainIntentsEnabled ?? false, + "stellar.settlementContractId": overrides.settlementContractId ?? "", + }; + return { get: (path: string) => values[path] } as ConfigService; +} + +function fakeStellarTxService() { + return { invokeContract: jest.fn() } as unknown as jest.Mocked; +} + +function fakePrismaService(): PrismaService { + return { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; +} + +function fakeProtocolParamsService(): ProtocolParamsService { + return { + snapshotForChain: jest.fn().mockReturnValue({ + version: 0, + feeBps: 30, + deadlineSeconds: 1800, + fillWindowSeconds: 600, + capturedAt: new Date().toISOString(), + }), + getCurrent: jest.fn().mockReturnValue({ version: 0, feeBps: 30, chains: {}, maxExposureRatio: 0.05, slashAmount: "100000000", activeSinceLedger: 0, adoptedAt: new Date().toISOString() }), + getPending: jest.fn().mockReturnValue(null), + getHistory: jest.fn().mockReturnValue([]), + } as unknown as ProtocolParamsService; +} + +function makeService( + configOverrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}, + stellarTx?: jest.Mocked, +) { + return new IntentsService( + new InMemoryIntentsRepository(), + fakeConfig(configOverrides), + stellarTx ?? fakeStellarTxService(), + fakePrismaService(), + fakeProtocolParamsService(), + ); +} + +function validCreateData() { + return { + user: Keypair.random().publicKey(), + srcChain: "ethereum" as const, + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" as const }, + srcAmount: "1000000", + dstToken: { contract: VALID_CONTRACT_ID, symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: Math.floor(Date.now() / 1000) + 1800, + }; +} + +async function buildService( + configOverrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}, + stellarTxService?: jest.Mocked, +): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: INTENTS_REPOSITORY, + useClass: InMemoryIntentsRepository, + }, + { + provide: ConfigService, + useValue: fakeConfig(configOverrides), + }, + { + provide: StellarTxService, + useValue: stellarTxService ?? fakeStellarTxService(), + }, + { + provide: PrismaService, + useValue: fakePrismaService(), + }, + { + provide: ProtocolParamsService, + useValue: fakeProtocolParamsService(), + }, + IntentsService, + ], + }).compile(); + + return module.get(IntentsService); +} + +describe("IntentsService", () => { + let service: IntentsService; + + beforeEach(() => { + service = makeService(); + }); + + it("seeds 5 intents on construction", async () => { + expect(await service.getAll()).toHaveLength(5); + }); + + it("getAll returns intents sorted by createdAt descending", async () => { + const all = await service.getAll(); + for (let i = 1; i < all.length; i++) { + expect(all[i - 1].createdAt).toBeGreaterThanOrEqual(all[i].createdAt); + } + }); + + it("create adds an open intent with a generated id", async () => { + const before = (await service.getAll()).length; + const deadline = Math.floor(Date.now() / 1000) + 1800; + const intent = await service.create({ + user: "GTEST...0000", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline, + }); + + expect(intent.state).toBe("open"); + expect(intent.intentId).toBeTruthy(); + expect(intent.deadline).toBe(deadline); + expect(await service.getAll()).toHaveLength(before + 1); + }); + + it("create defaults deadline to now + 1800 when omitted", async () => { + const before = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST...0000", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: undefined as unknown as number, + }); + + expect(intent.deadline).toBeGreaterThanOrEqual(before + 1800); + }); + + it("get returns undefined for an unknown id", async () => { + expect(await service.get("does-not-exist")).toBeUndefined(); + }); + + it("update mutates and returns the patched intent", async () => { + const [existing] = await service.getByState("open"); + const updated = await service.update(existing.intentId, { state: "accepted", solver: "SOLVER_X" }); + + expect(updated?.state).toBe("accepted"); + expect(updated?.solver).toBe("SOLVER_X"); + expect((await service.get(existing.intentId))?.state).toBe("accepted"); + }); + + it("update returns null for an unknown id", async () => { + expect(await service.update("does-not-exist", { state: "cancelled" })).toBeNull(); + }); + + it("getByUser is case-insensitive", async () => { + const [existing] = await service.getAll(); + const found = await service.getByUser(existing.user.toLowerCase()); + expect(found.some((i) => i.intentId === existing.intentId)).toBe(true); + }); + + it("getByState only returns intents in that state", async () => { + for (const intent of await service.getByState("filled")) { + expect(intent.state).toBe("filled"); + } + }); + + describe("acceptIfOpen", () => { + it("transitions an open intent to accepted and returns it", async () => { + const [open] = await service.getByState("open"); + const result = await service.acceptIfOpen(open.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + expect(result!.state).toBe("accepted"); + expect(result!.solver).toBe("SOLVER_X"); + expect((await service.get(open.intentId))!.state).toBe("accepted"); + }); + + it("returns null for a non-existent intent", async () => { + expect(await service.acceptIfOpen("does-not-exist", "SOLVER_X")).toBeNull(); + }); + + it("returns null when the intent is already accepted", async () => { + const [accepted] = await service.getByState("accepted"); + expect(await service.acceptIfOpen(accepted.intentId, "SOLVER_X")).toBeNull(); + }); + + it("only the first caller wins under simulated concurrency", async () => { + const [open] = await service.getByState("open"); + const results = await Promise.all( + Array.from({ length: 10 }, (_, i) => + service.acceptIfOpen(open.intentId, `SOLVER_${i}`), + ), + ); + + const successes = results.filter((r) => r !== null); + expect(successes).toHaveLength(1); + expect(successes[0]!.state).toBe("accepted"); + }); + + // ----------------------------------------------------------------------- + // Per-chain fill-window tests (issue: chain-aware fill window) + // ----------------------------------------------------------------------- + + it("sets deadline to now + stellar fill window (120 s) for a stellar intent", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_STELLAR_CHAIN1", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 900, + }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; + // Allow a 2-second tolerance for test execution time + expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); + }); + + it("sets deadline to now + ethereum fill window (1800 s) for an ethereum intent", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_ETHEREUM_CHAIN1", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; + // Allow a 2-second tolerance for test execution time + expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); + }); + + it("stellar and ethereum accepted intents get distinct (non-equal) fill deadlines", async () => { + const now = Math.floor(Date.now() / 1000); + + const stellarIntent = await service.create({ + user: "GTEST_STELLAR_DIFF1", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 900, + }); + const ethIntent = await service.create({ + user: "GTEST_ETHEREUM_DIFF1", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + + const stellarResult = await service.acceptIfOpen(stellarIntent.intentId, "SOLVER_STELLAR"); + const ethResult = await service.acceptIfOpen(ethIntent.intentId, "SOLVER_ETH"); + + expect(stellarResult).not.toBeNull(); + expect(ethResult).not.toBeNull(); + + // Ethereum solver gets a materially larger fill window than Stellar + expect(ethResult!.deadline).toBeGreaterThan(stellarResult!.deadline); + + // Confirm the windows match the config constants exactly (allowing 2 s clock drift) + const stellarWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; + const ethWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; + expect(ethWindow).toBeGreaterThan(stellarWindow); // sanity-check on config + }); + + it("falls back to DEFAULT_FILL_WINDOW_SECONDS for an unknown chain", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_UNKNOWN_CHAIN01", + srcChain: "stellar", // create as valid chain, then patch for test + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + // Manually patch to an unknown chain to exercise the fallback + await service.update(intent.intentId, { srcChain: "unknown_chain" as never }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + expect(result!.deadline).toBeGreaterThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS + 2); + }); + }); // end describe("acceptIfOpen") + + describe("fillIfAccepted", () => { + it("transitions an accepted intent to filled when solver matches", async () => { + const [accepted] = await service.getByState("accepted"); + const result = await service.fillIfAccepted(accepted.intentId, accepted.solver!, { + fillAmount: "100", + txHash: "test-hash", + filledAt: Math.floor(Date.now() / 1000), + }); + + expect(result).not.toBeNull(); + expect(result!.state).toBe("filled"); + expect(result!.fillAmount).toBe("100"); + }); + + it("returns null when solver does not match", async () => { + const [accepted] = await service.getByState("accepted"); + const result = await service.fillIfAccepted(accepted.intentId, "WRONG_SOLVER", { + fillAmount: "100", + }); + expect(result).toBeNull(); + }); + + it("returns null for a non-existent intent", async () => { + expect(await service.fillIfAccepted("nope", "SOLVER_X", {})).toBeNull(); + }); + + it("only the first caller wins under simulated concurrency", async () => { + const [accepted] = await service.getByState("accepted"); + const results = await Promise.all( + Array.from({ length: 10 }, () => + service.fillIfAccepted(accepted.intentId, accepted.solver!, { + fillAmount: "100", + txHash: "race-hash", + filledAt: Math.floor(Date.now() / 1000), + }), + ), + ); + + const successes = results.filter((r) => r !== null); + expect(successes).toHaveLength(1); + expect(successes[0]!.state).toBe("filled"); + }); + }); + + describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => { + it("stays fully in the repository when the flag is off, never touching StellarTxService", async () => { + const stellarTxService = fakeStellarTxService(); + const svc = makeService({ onchainIntentsEnabled: false }, stellarTxService); + + const intent = await svc.create(validCreateData()); + + expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); + expect(await svc.get(intent.intentId)).toEqual(intent); + }); + + it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => { + const stellarTxService = fakeStellarTxService(); + stellarTxService.invokeContract.mockResolvedValue({ hash: "deadbeef", status: "SUCCESS" } as never); + const svc = makeService( + { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, + stellarTxService, + ); + + const data = validCreateData(); + const intent = await svc.create(data); + + expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1); + const call = stellarTxService.invokeContract.mock.calls[0][0]; + expect(call.contractId).toBe(VALID_CONTRACT_ID); + expect(call.method).toBe("create_intent"); + + // Response shape: the in-memory and on-chain paths return the same keys. + // usdValueAtCreate (#440) and paramsVersion (#500) are both stamped by + // persistNewIntent, so they appear on every newly created intent. + expect(Object.keys(intent).sort()).toEqual( + Object.keys({ + intentId: "", + user: "", + srcChain: "", + srcToken: "", + srcAmount: "", + dstToken: "", + minDstAmount: "", + state: "", + createdAt: 0, + deadline: 0, + paramsVersion: 0, + usdValueAtCreate: 0, + // Issue #385: stamped on every create (set on the onchain path, + // undefined otherwise) so both paths return the same shape. + pendingTxHash: "", + pendingOp: "", + // Issue #403: stamped on every create so the deposit-verification + // loop can distinguish pending intents from skipped/disabled ones. + srcVerified: false, + srcVerification: "", + }).sort(), + ); + expect(await svc.get(intent.intentId)).toBeDefined(); + }); + + it("rejects with a clear error and does not create the intent when SETTLEMENT_CONTRACT_ID is unset", async () => { + const stellarTxService = fakeStellarTxService(); + const service = makeService({ onchainIntentsEnabled: true }, stellarTxService); + const before = (await service.getAll()).length; + + await expect(service.create(validCreateData())).rejects.toMatchObject({ + message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"), + }); + expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); + expect(await service.getAll()).toHaveLength(before); + }); + + it("rejects and does not create the intent when the on-chain call fails", async () => { + const stellarTxService = fakeStellarTxService(); + stellarTxService.invokeContract.mockRejectedValue(new Error("submission failed after 5 attempts")); + const svc = makeService( + { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, + stellarTxService, + ); + const before = (await svc.getAll()).length; + + await expect(svc.create(validCreateData())).rejects.toThrow(/settlement contract/i); + expect(await svc.getAll()).toHaveLength(before); + }); + }); + + // --------------------------------------------------------------------------- + // Audit trail (issue #217 / #62) + // --------------------------------------------------------------------------- + + describe("appendAuditEntry / getAuditLog", () => { + it("returns an empty array for an intent with no audit entries", () => { + expect(service.getAuditLog("no-such-intent")).toEqual([]); + }); + + it("appends a single entry and getAuditLog returns it", () => { + service.appendAuditEntry("intent-1", "cancelled", "USER_ADDR", "user cancelled"); + const log = service.getAuditLog("intent-1"); + expect(log).toHaveLength(1); + expect(log[0]).toMatchObject({ + toState: "cancelled", + actor: "USER_ADDR", + reason: "user cancelled", + }); + expect(log[0].timestamp).toBeTruthy(); // ISO timestamp + }); + + it("appends multiple entries in order and getAuditLog returns oldest-first", async () => { + service.appendAuditEntry("intent-2", "accepted", "SOLVER_A", "solver accepted"); + await new Promise((r) => setTimeout(r, 5)); // small gap so timestamps differ + service.appendAuditEntry("intent-2", "filled", "SOLVER_A", "solver filled"); + + const log = service.getAuditLog("intent-2"); + expect(log).toHaveLength(2); + expect(log[0].toState).toBe("accepted"); + expect(log[1].toState).toBe("filled"); + }); + + it("stores optional metadata in the entry", () => { + service.appendAuditEntry("intent-3", "expired", "system", "deadline passed", { + deadline: 1234567890, + sweepedAt: 1234567900, + }); + const log = service.getAuditLog("intent-3"); + expect(log[0].metadata).toEqual({ deadline: 1234567890, sweepedAt: 1234567900 }); + }); + + it("does not mix entries across different intentIds", () => { + service.appendAuditEntry("intent-A", "cancelled", "USER_A", "cancel A"); + service.appendAuditEntry("intent-B", "expired", "system", "expire B"); + + expect(service.getAuditLog("intent-A")).toHaveLength(1); + expect(service.getAuditLog("intent-B")).toHaveLength(1); + expect(service.getAuditLog("intent-A")[0].toState).toBe("cancelled"); + expect(service.getAuditLog("intent-B")[0].toState).toBe("expired"); + }); + + it("fires a DB write via PrismaService on each append (non-blocking)", async () => { + const prismaService = { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService, fakeProtocolParamsService()); + + svc.appendAuditEntry("intent-db", "slashed", "system", "missed fill", { foo: "bar" }); + + // The DB write is fire-and-forget — wait one tick for the promise chain + await new Promise((r) => setImmediate(r)); + + const mockPrisma = prismaService as unknown as { + intentAuditLog: { create: jest.Mock }; + }; + expect(mockPrisma.intentAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + intentId: "intent-db", + toState: "slashed", + actor: "system", + reason: "missed fill", + }), + }), + ); + }); + + it("does NOT throw when the DB write fails — logs an error but returns normally", async () => { + const prismaService = { + intentAuditLog: { + create: jest.fn().mockRejectedValue(new Error("DB is down")), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService, fakeProtocolParamsService()); + + // Should not throw synchronously + expect(() => + svc.appendAuditEntry("intent-fail", "expired", "system", "deadline"), + ).not.toThrow(); + + // In-memory log still has the entry + expect(svc.getAuditLog("intent-fail")).toHaveLength(1); + + // Wait for the rejected promise — should not propagate + await new Promise((r) => setImmediate(r)); + // No unhandled rejection here (jest would fail the test if one occurred) + }); + }); +}); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index fd81a2af..d06733e2 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -8,8 +8,12 @@ import { import { ConfigService } from "@nestjs/config"; import { v4 as uuidv4 } from "uuid"; import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; -import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; +import { Intent, IntentAuditEntry, IntentState, PendingIntentOp } from "./intents.types"; +import { INTENTS_REPOSITORY, IIntentsRepository, isVersionConflict, MutationResult } from "./intents.repository"; +import { INTENTS_UNIT_OF_WORK, IIntentsUnitOfWork } from "./intents.unit-of-work"; +import { IntentDeadlineScheduler } from "./intents-deadline.jobs"; +import { IOutboxWriter, NewOutboxEntry } from "../soroban/outbox.repository"; +import { buildOutboxInvocation, createIntentEntry } from "../soroban/outbox-operations"; import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, @@ -28,7 +32,9 @@ import { FeatureFlagService } from "../flags/feature-flag.service"; const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slashed"]; /** - * Sentinel `from_state` for the transition into "open". + * Sentinel `from_state` for the transition at intent creation — into `open` + * on the in-memory path, or into `pending_open` when the onchain rollout + * parks the fresh intent (issue #385). * * Not an {@link IntentState}: creation has no prior state, and inventing one * would put a value in the `from_state` label that no lifecycle edge can @@ -37,6 +43,34 @@ const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slash */ const NONE_STATE = "none"; +/** pending_* state each chain write parks in (issue #385). */ +const PENDING_STATE_BY_OP: Record = { + create: "pending_open", + accept: "pending_accepted", + fill: "pending_filled", + cancel: "pending_cancelled", +}; + +/** Confirmed base state each pending_* marker settles back to (issue #385). */ +const CONFIRMED_STATE_BY_PENDING: Partial> = { + pending_open: "open", + pending_accepted: "accepted", + pending_filled: "filled", + pending_cancelled: "cancelled", +}; + +/** + * Settlement-contract method each write broadcasts (issue #385). Names mirror + * the shadow hooks (`observeAccept`/`observeFill`/`cancelIfOpen`) so the + * simulated and the real invocation stay the same call. + */ +const ONCHAIN_METHOD_BY_OP: Record = { + create: "create_intent", + accept: "accept_intent", + fill: "fill_intent", + cancel: "cancel_intent", +}; + /** * Runtime check that `transition` is one of the five the shadow monitor models. * @@ -51,6 +85,43 @@ function isKnownShadowTransition(transition: ShadowTransition): boolean { /** How long a completed idempotency-key result stays replayable. */ const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours +/** + * Compute the USD value of a base-unit amount at a given token price (issue #440). + * + * Uses integer arithmetic for the amount (BigInt) so large base-unit values do + * not lose precision before the float conversion; the price is scaled to 1e8 + * to keep the multiplication in integer space. Returns `undefined` when the + * price is unknown — historical rows are never backfilled with fabricated + * values. + */ +function computeUsdValue( + srcAmount: string, + decimals: number, + priceUsd: number | undefined, +): number | undefined { + if (priceUsd === undefined || priceUsd === null || !Number.isFinite(priceUsd)) return undefined; + try { + const amount = BigInt(srcAmount); + const scale = 10n ** BigInt(decimals); + const scaled = amount * BigInt(Math.round(priceUsd * 1e8)); + return Number(scaled / (scale * 100_000_000n)); + } catch { + return undefined; + } +} + +/** + * Narrow an optimistic-concurrency mutation result to the written record. + * + * `VersionConflict` means a concurrent writer won the race — the guarded + * transition did not happen — which every service-level caller treats exactly + * like "no match": `null`. The conflict details remain available to callers + * that talk to the repository directly (see `etag.ts` / `preconditionFailed`). + */ +function written(result: MutationResult): Intent | null { + return result !== null && !isVersionConflict(result) ? result : null; +} + /** * Maximum number of simultaneously open (state = "open" | "accepted") intents * allowed per user address. @@ -68,6 +139,13 @@ const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours */ export const MAX_OPEN_INTENTS_PER_USER = 50; +/** + * Upper bound on re-read → retry attempts inside {@link IntentsService.mutateWithRetry}. + * Three conflicts in a row means a hot row; giving up keeps a retrying writer + * from starving concurrent readers (issue #405). + */ +export const MAX_VERSION_RETRIES = 3; + /** Payload for creating a new intent. */ export type NewIntentData = Omit; @@ -133,8 +211,38 @@ export class IntentsService { */ @Optional() private readonly metricsService?: MetricsService, @Optional() private readonly flags?: FeatureFlagService, + /** + * Transactional outbox unit of work (issue #396). + * + * Present only where the durable outbox is wired (and in the crash-injection + * harnesses): while it is injected, `create()` commits the intent row and its + * `create_intent` outbox row atomically and the relay submits later, instead + * of calling the settlement contract inline. Absent — the default graph at + * HEAD — creation keeps the direct, synchronous registration path. + */ + @Optional() @Inject(INTENTS_UNIT_OF_WORK) + private readonly unitOfWork?: IIntentsUnitOfWork, + /** + * Deadline-job scheduler (issue #550). Registers the expire/fill-window + * wake-ups whenever an intent is created or its fill window starts, so the + * sweeper settles deadlines through the jobs queue instead of polling. + * `@Optional()` because unit harnesses that never exercise deadline jobs + * construct this service directly without one. + */ + @Optional() private readonly deadlines?: IntentDeadlineScheduler, ) {} + /** + * Lifecycle hook (Nest `OnModuleDestroy`): drops the in-memory idempotency + * caches. This service owns no timers — deadline wake-ups live in the jobs + * queue — so the caches are the only teardown the instance has. Called + * directly by the unit harnesses after each case for the same reason. + */ + onModuleDestroy(): void { + this.idempotencyCache.clear(); + this.idempotencyInFlight.clear(); + } + /** * Logs the store size and evicts stale terminal intents from the in-memory * adapter when it is the active backend. This keeps the memory footprint @@ -287,6 +395,41 @@ export class IntentsService { * Build, optionally register on-chain, and persist a brand-new intent. * Contains no idempotency logic — deduplication is the caller's concern. */ + /** + * Creation-time source-deposit-verification verdict (issue #403). + * + * - `evm.depositVerificationEnabled=false` → everything is marked verified + * up front (`skipped`) so the verify loop never picks it up. + * - Non-EVM source chains (Stellar) are out of the EVM verifier's scope → + * `skipped`, verified. + * - Otherwise the intent is born `pending` / unverified until the + * source-deposit verification service ticks it. + */ + private initialSrcVerification( + srcChain: NewIntentData["srcChain"], + now: number, + ): Pick { + const enabled = + this.configService.get("evm", { infer: true })?.depositVerificationEnabled === true; + if (!enabled) { + return { + srcVerified: true, + srcVerification: { + status: "skipped", + checkedAt: now, + detail: "deposit verification disabled", + }, + }; + } + if (srcChain === "stellar") { + return { + srcVerified: true, + srcVerification: { status: "skipped", checkedAt: now, detail: "non-EVM source chain" }, + }; + } + return { srcVerified: false, srcVerification: { status: "pending", checkedAt: now } }; + } + private async persistNewIntent( data: Omit, ): Promise { @@ -299,41 +442,113 @@ export class IntentsService { const defaultDeadline = data.deadline ?? now + paramsSnapshot.deadlineSeconds; const intent: Intent = { + // Issue #403: stamp the creation-time source-deposit verdict first so + // the row is self-describing from its first write — the verify loop + // picks intents up by `srcVerified === false` and never re-checks + // "skipped"/"grandfathered" ones. An explicit caller-supplied verdict + // (imports, seeds) wins over this default. + ...this.initialSrcVerification(data.srcChain, now), ...data, intentId: uuidv4(), state: "open", createdAt: now, deadline: defaultDeadline, paramsVersion: paramsSnapshot.version, + usdValueAtCreate: computeUsdValue( + data.srcAmount, + data.srcToken.decimals, + data.srcToken.priceUSD, + ), + // Issue #385: both creation paths carry the pending keys — undefined + // until a chain write is in flight — so the in-memory and on-chain + // paths return an identical shape. + pendingTxHash: undefined, + pendingOp: undefined, }; // ONCHAIN_INTENTS_ENABLED is the default; the `onchain-intents-enabled` // runtime flag (issue #495) can roll it out per chain / percentage. - const onchain = this.flags - ? await this.flags.getBooleanValue("onchain-intents-enabled", { - targetingKey: intent.intentId, - chain: intent.srcChain, - }) - : this.configService.get("onchainIntentsEnabled", { infer: true }); - if (onchain) { - await this.registerOnChain(intent); + const onchain = await this.isOnchainWrite(intent); + if (onchain && this.unitOfWork) { + // Issue #396: intent row + create_intent outbox row commit atomically; + // OutboxRelayService submits afterwards, never inside this request. + // Issue #385: nothing has reached the chain yet, so the row is born + // `pending_open` — `pendingTxHash` is attached once the confirmation + // watcher observes the relay's transaction. + intent.state = "pending_open"; + intent.pendingOp = "create"; + await this.unitOfWork.run(async ({ intents, outbox }) => { + await this.enqueueOnchain(outbox, intent); + await intents.save(intent); + }); + } else if (onchain) { + // Registration must land before the row exists: a failed broadcast + // rejects the whole creation, leaving no dangling `pending_open` record + // behind (issue #385). + const txHash = await this.registerOnChain(intent); + intent.state = "pending_open"; + intent.pendingOp = "create"; + intent.pendingTxHash = txHash; + await this.repo.save(intent); + } else { + await this.repo.save(intent); } - - await this.repo.save(intent); + // Issue #550: wake the sweeper at this intent's deadline through the jobs + // queue instead of relying on the legacy 30s poll. + this.deadlines?.scheduleExpire(intent); // Creation is the entry edge of the funnel: the `vortex:intent:*` recording // rules count transitions *into* each state, so without this the intent // dashboard would start every conversion ratio from zero. `from_state` is // the sentinel "none" — an intent that does not exist yet has no state. - this.countTransition(NONE_STATE, "open"); + this.countTransition(NONE_STATE, intent.state); return intent; } /** - * Registers `intent` with the settlement contract. Only called when - * ONCHAIN_INTENTS_ENABLED is on; while that flag is off, create() stays - * fully in-memory (the rollout fallback). + * Queues the settlement contract's `create_intent` call for `intent` on the + * outbox (issue #396), for the unit-of-work path of {@link persistNewIntent}. + * Nothing is broadcast here — the relay submits afterwards, so the request + * never waits on Soroban and an Soroban outage cannot fail intent creation. */ - private async registerOnChain(intent: Intent): Promise { + private async enqueueOnchain(outbox: IOutboxWriter, intent: Intent): Promise { + const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); + if (!contractId) { + throw new ServiceUnavailableException( + "On-chain intent registration is enabled but SETTLEMENT_CONTRACT_ID is not configured", + ); + } + const entry = createIntentEntry(intent); + // Validates the entry encodes to a contract call before it can become a + // poison row. + buildOutboxInvocation(entry, contractId); + await outbox.enqueue(entry); + this.logger.log(`Queued on-chain registration for intent ${intent.intentId}`); + } + + /** + * Does a write for `intent` need the settlement contract? Resolved from the + * runtime rollout flag when the flag service is wired (issue #495), from + * `ONCHAIN_INTENTS_ENABLED` otherwise — shared by creation and the later + * accept/fill/cancel writes so they can never disagree about a rollout. + */ + private async isOnchainWrite(intent: Intent): Promise { + if (this.flags) { + return this.flags.getBooleanValue("onchain-intents-enabled", { + targetingKey: intent.intentId, + chain: intent.srcChain, + }); + } + return this.configService.get("onchainIntentsEnabled", { infer: true }); + } + + /** + * Registers `intent` with the settlement contract, returning the broadcast + * transaction hash (issue #385 — stamped as `pendingTxHash`). Only called + * when ONCHAIN_INTENTS_ENABLED is on and no outbox unit of work is wired; + * while that flag is off, create() stays fully in-memory (the rollout + * fallback). + */ + private async registerOnChain(intent: Intent): Promise { const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); if (!contractId) { throw new ServiceUnavailableException( @@ -348,6 +563,7 @@ export class IntentsService { args: this.buildCreateIntentArgs(intent), }); this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`); + return result.hash; } catch (err) { this.logger.error( `Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`, @@ -513,7 +729,10 @@ export class IntentsService { } /** - * Count the number of intents in "open" or "accepted" state for a user. + * Count the number of intents standing for a user: `open` or `accepted`, + * plus their `pending_open` / `pending_accepted` in-flight variants + * (issue #385) — a chain write that has not confirmed yet still occupies + * the user's {@link MAX_OPEN_INTENTS_PER_USER} slot. * * Used by IntentsController.create() to enforce MAX_OPEN_INTENTS_PER_USER. * The query is a simple filter over findByUser so it works identically @@ -524,7 +743,11 @@ export class IntentsService { async countOpenByUser(user: string): Promise { const userIntents = await this.repo.findByUser(user); return userIntents.filter( - (i) => i.state === "open" || i.state === "accepted", + (i) => + i.state === "open" || + i.state === "accepted" || + i.state === "pending_open" || + i.state === "pending_accepted", ).length; } @@ -538,14 +761,44 @@ export class IntentsService { * it is used by test setup only. It is logged so that a future production * caller is caught in review rather than silently skewing the dashboards. */ - async update(id: string, patch: Partial): Promise { + async update( + id: string, + patch: Partial, + expectedVersion?: number, + ): Promise { if (patch.state !== undefined) { this.logger.warn( `[state-machine] update(${id}) carries a state patch ("${patch.state}"); ` + `this bypasses the guarded transitions and their observers`, ); } - return this.repo.update(id, patch); + return written(await this.repo.update(id, patch, expectedVersion)); + } + + /** + * Re-read → mutate loop for writers for whom retrying is semantically safe + * (the sweeper, quote persistence, deposit verification). `mutate` receives + * the freshly-read intent and returns the versioned mutation to attempt, or + * `undefined` when the intent no longer needs changing — which ends the loop + * with `null`. Bounded by {@link MAX_VERSION_RETRIES}; if every attempt + * conflicts, the last VersionConflict is returned. + */ + async mutateWithRetry( + id: string, + mutate: (current: Intent) => Promise | MutationResult | undefined, + maxAttempts = MAX_VERSION_RETRIES, + ): Promise { + let last: MutationResult = null; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const current = await this.repo.findById(id); + if (!current) return null; + const pending = mutate(current); + if (pending === undefined) return null; + last = await pending; + if (!isVersionConflict(last)) return last; + } + this.logger.warn(`[occ] gave up on intent ${id} after ${maxAttempts} version conflicts`); + return last; } /** Amend an open intent without changing its ID or creation history. */ @@ -574,11 +827,26 @@ export class IntentsService { const nowSec = now ?? Math.floor(Date.now() / 1000); const fillWindow = CHAIN_FILL_WINDOW_DEFAULTS[intent.srcChain] ?? DEFAULT_FILL_WINDOW_SECONDS; - const updated = await this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); - if (updated !== null) this.countTransition("open", "accepted"); + const updated = written(await this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec)); + if (updated !== null) { + this.countTransition("open", "accepted"); + // Issue #550: the fill window is now the binding deadline — schedule the + // fill-window job so the sweeper settles it through the jobs queue. + this.deadlines?.scheduleFillWindow(updated); + } if (this.beginShadowObservation()) { this.observeAccept(updated ?? intent, solver, updated !== null); } + if (updated === null) return null; + // Issue #385: with the onchain rollout on, the accept is not final until + // the contract has seen it — broadcast and park pending_accepted. + if (await this.isOnchainWrite(updated)) { + return this.parkWithBroadcast(updated, "accept", () => [ + nativeToScVal(updated.intentId, { type: "string" }), + new Address(updated.solver ?? solver).toScVal(), + nativeToScVal(updated.deadline, { type: "u64" }), + ]); + } return updated; } @@ -610,7 +878,7 @@ export class IntentsService { now?: number, ): Promise { const nowSec = now ?? Math.floor(Date.now() / 1000); - const updated = await this.repo.fillIfAccepted(id, solver, patch, nowSec); + const updated = written(await this.repo.fillIfAccepted(id, solver, patch, nowSec)); if (updated !== null) this.countTransition("accepted", "filled"); if (this.beginShadowObservation()) { // Report from `patch` rather than re-reading: on a lost race the stored @@ -620,6 +888,16 @@ export class IntentsService { // pre-empted it. this.observeFill(id, solver, patch.fillAmount, patch.txHash, updated !== null); } + if (updated === null) return null; + // Issue #385: park the fill pending until the contract confirms it. + if (await this.isOnchainWrite(updated)) { + return this.parkWithBroadcast(updated, "fill", () => [ + nativeToScVal(updated.intentId, { type: "string" }), + new Address(updated.solver ?? solver).toScVal(), + nativeToScVal(BigInt(patch.fillAmount ?? "0"), { type: "i128" }), + nativeToScVal(patch.txHash ?? "", { type: "string" }), + ]); + } return updated; } @@ -651,7 +929,7 @@ export class IntentsService { * (e.g. a concurrent accept() or sweeper expiry already transitioned it). */ async cancelIfOpen(id: string): Promise { - const updated = await this.repo.cancelIfOpen(id); + const updated = written(await this.repo.cancelIfOpen(id)); if (updated !== null) this.countTransition("open", "cancelled"); if (this.beginShadowObservation()) { const subject = updated ?? (await this.repo.findById(id)); @@ -668,16 +946,118 @@ export class IntentsService { ); } } + if (updated === null) return null; + // Issue #385: the cancellation is not final until the contract has seen + // it — broadcast and park pending_cancelled. + if (await this.isOnchainWrite(updated)) { + return this.parkWithBroadcast(updated, "cancel", () => [ + nativeToScVal(updated.intentId, { type: "string" }), + new Address(updated.user).toScVal(), + ]); + } return updated; } + /** + * Park an intent in the `pending_*` state matching `op` (issue #385). + * + * Parking is a confirmation marker, not a lifecycle edge: it records that + * the guarded off-chain write for `op` has committed — and, when given, the + * hash of its chain broadcast — while the settlement contract has not yet + * confirmed it. It therefore writes the state directly rather than through + * {@link canTransition}, because `filled → pending_filled` would otherwise + * be an illegal edge out of a terminal state. {@link confirmIntent} settles + * the marker back to the base state. + * + * @param intentId - Intent to park. + * @param op - Chain write whose confirmation is being awaited. + * @param txHash - Broadcast transaction hash, when one was obtained. + * @returns The parked intent, or null when it does not exist. + */ + async transitionToOnChainPending( + id: string, + op: PendingIntentOp, + txHash?: string, + ): Promise { + const intent = await this.repo.findById(id); + if (!intent) return null; + const updated = written( + await this.repo.update(id, { + state: PENDING_STATE_BY_OP[op], + pendingOp: op, + pendingTxHash: txHash, + }), + ); + if (updated !== null) this.countTransition(intent.state, updated.state); + return updated; + } + + /** + * Resolve a `pending_*` marker to its confirmed base state (issue #385), + * clearing `pendingTxHash`/`pendingOp`. + * + * @param intentId - Intent whose in-flight write has been observed on chain. + * @returns The confirmed intent, or null when it does not exist or is not + * in a pending state — confirmation only means something for a write that + * is actually in flight. + */ + async confirmIntent(id: string): Promise { + const intent = await this.repo.findById(id); + if (!intent) return null; + const confirmed = CONFIRMED_STATE_BY_PENDING[intent.state]; + if (!confirmed) return null; + const updated = written( + await this.repo.update(id, { + state: confirmed, + pendingTxHash: undefined, + pendingOp: undefined, + }), + ); + if (updated !== null) this.countTransition(intent.state, confirmed); + return updated; + } + + /** + * Issue #385: attempt the settlement-contract broadcast for a committed + * write, then park the intent in its `pending_*` state. + * + * The park is unconditional — with the onchain rollout on, the base state + * is never claimed final — while the broadcast is best-effort: argument + * encoding (a malformed address) or an RPC failure logs and parks without + * a hash instead of failing a write that has already committed. Settling + * the marker is {@link confirmIntent}'s job. + */ + private async parkWithBroadcast( + intent: Intent, + op: PendingIntentOp, + buildArgs: () => xdr.ScVal[], + ): Promise { + let txHash: string | undefined; + try { + const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); + if (!contractId) throw new Error("SETTLEMENT_CONTRACT_ID is not configured"); + const result = await this.stellarTxService.invokeContract({ + contractId, + method: ONCHAIN_METHOD_BY_OP[op], + args: buildArgs(), + }); + txHash = result.hash; + } catch (err) { + this.logger.error( + `On-chain ${op} broadcast failed for intent ${intent.intentId}: ` + + `${(err as Error).message} — parked pending without a tx hash`, + ); + } + return (await this.transitionToOnChainPending(intent.intentId, op, txHash)) ?? intent; + } + /** * Atomically expire an intent only if it is currently "open". * Used by the sweeper so a concurrent user cancel() or solver accept() * always wins the race. */ async expireIfOpen(id: string): Promise { - const updated = await this.repo.expireIfOpen(id); + const updated = written(await this.repo.expireIfOpen(id)); if (updated !== null) this.countTransition("open", "expired"); if (this.beginShadowObservation()) { const subject = updated ?? (await this.repo.findById(id)); @@ -705,7 +1085,7 @@ export class IntentsService { id: string, patch: { slashedAt: number; slashReason: string }, ): Promise { - const updated = await this.repo.slashIfAccepted(id, patch); + const updated = written(await this.repo.slashIfAccepted(id, patch)); if (updated !== null) this.countTransition("accepted", "slashed"); if (this.beginShadowObservation()) { const subject = updated ?? (await this.repo.findById(id)); @@ -738,7 +1118,9 @@ export class IntentsService { * or already has a later deadline. */ async extendDeadlineIfAccepted(id: string, newDeadline: number): Promise { - return this.repo.extendDeadlineIfAccepted(id, newDeadline); + const updated = written(await this.repo.extendDeadlineIfAccepted(id, newDeadline)); + if (updated) this.deadlines?.scheduleFillWindow(updated); + return updated; } // --------------------------------------------------------------------------- diff --git a/src/intents/intents.types.ts b/src/intents/intents.types.ts index 7fdda741..b75e5c16 100644 --- a/src/intents/intents.types.ts +++ b/src/intents/intents.types.ts @@ -1,12 +1,6 @@ -/** - * Core intent types for vortex-backend. - * - * These types are the source of truth for all modules. The package-level - * types in src/types/index.ts mirror a subset of these for SDK consumers. - */ - -// ─── Chains ────────────────────────────────────────────────────────────────── +import type { DutchAuction } from "../auctions/dutch"; +/** * Single source of truth for every chain the protocol recognises. * `SupportedChain` is derived from this tuple so all three consumers * (intents.types.ts, create-intent.dto.ts, tokens.data.ts) stay in sync @@ -24,8 +18,6 @@ export const SUPPORTED_CHAINS = [ export type SupportedChain = (typeof SUPPORTED_CHAINS)[number]; -// ─── Intent states ──────────────────────────────────────────────────────────── - /** * The Stellar chain identifier, named for readability at call sites that would * otherwise repeat the literal. @@ -69,12 +61,17 @@ export const INTENT_STATES = [ "cancelled", "expired", "slashed", + // Submitted-but-unconfirmed variants (issue #385). A solver-side write that + // has been broadcast but not yet observed on-chain parks the intent in the + // matching pending_* state until the confirmation watcher resolves it. + "pending_open", + "pending_accepted", + "pending_filled", + "pending_cancelled", ] as const; export type IntentState = (typeof INTENT_STATES)[number]; -// ─── Token types ────────────────────────────────────────────────────────────── - export interface TokenInfo { address: string; symbol: string; @@ -82,7 +79,6 @@ export interface TokenInfo { decimals: number; chain: SupportedChain; logoURI?: string; - priceUSD?: number | null; priceUSD?: number; } @@ -90,36 +86,6 @@ export interface StellarToken { contract: string; symbol: string; decimals: number; - priceUSD?: number | null; -} - -// ─── Source-verification ────────────────────────────────────────────────────── - -export type VerificationStatus = "pending" | "verified" | "failed" | "grandfathered"; - -export interface SrcVerificationResult { - status: VerificationStatus; - checkedAt: number; - blockNumber?: string; - blockHash?: string; - detail?: string; - receivedAmount?: string; -} - -// ─── Intent ────────────────────────────────────────────────────────────────── - -/** - * Canonical in-memory representation of a cross-chain swap intent. - * - * Bigint amounts (srcAmount, minDstAmount, fillAmount, quotedDstAmount, feeAmount) - * are stored as decimal strings throughout — never coerced through `Number` so - * precision is preserved for large ERC-20 amounts. - * - * Issue #410 adds `srcTokenId` / `dstTokenId` / `srcDecimals` / `dstDecimals` - * which are populated by the create path when the token is in the registry. - * They are intentionally optional so the expand/contract migration can land - * without breaking the in-memory or dual-write adapters. - */ priceUSD?: number; } @@ -128,14 +94,13 @@ export interface Intent { user: string; srcChain: SupportedChain; srcToken: TokenInfo; - srcAmount: string; - dstToken: StellarToken; - minDstAmount: string; - quotedDstAmount?: string; - acceptedDstAmount?: string; srcAmount: string; // bigint as string dstToken: StellarToken; minDstAmount: string; + /** Optional off-chain Dutch auction terms attached at creation (issue #507). */ + auction?: DutchAuction; + /** Destination amount locked when a solver accepted the intent. */ + acceptedDstAmount?: string; quotedDstAmount?: string; // best quote from solvers solver?: string; state: IntentState; @@ -143,42 +108,14 @@ export interface Intent { deadline: number; filledAt?: number; fillAmount?: string; - feeAmount?: string; - txHash?: string; - slashedAt?: number; - slashReason?: string; - - // Optimistic concurrency (#404) - version: number; - - // Dutch auction (#429) - auction?: Record; - - // Source-deposit verification (#403) - srcVerified: boolean; - srcTxHash?: string; - srcVerification?: SrcVerificationResult; - - // Governance params snapshot at creation - paramsVersion?: string; - - // ── #410: FK columns (populated at create-time when token is in registry) ── - srcTokenId?: string; - dstTokenId?: string; - /** Immutable snapshot of src token decimals at intent creation time. */ - srcDecimals?: number; - /** Immutable snapshot of dst token decimals at intent creation time. */ - dstDecimals?: number; -} - -export interface IntentAuditEntry { - timestamp: string; - toState: IntentState; - actor: string; - reason: string; - metadata?: Record; feeAmount?: string; // realized protocol fee in dst token base units txHash?: string; // fill tx on Stellar + /** Fill verification state written by the fill verifier (issue #471). */ + fillVerificationState?: "pending" | "verified" | "rejected"; + /** Human-readable reason when fill verification rejected the fill. */ + fillVerificationReason?: string; + /** ISO-8601 timestamp of the last fill-verification attempt. */ + fillVerifiedAt?: string; slashedAt?: number; slashReason?: string; /** @@ -188,6 +125,85 @@ export interface IntentAuditEntry { * Absent on intents created before issue #500 was deployed. */ paramsVersion?: number; + /** + * USD value of `srcAmount` at creation time, computed from the resolved + * source-token price. Powers the `minAmountUsd` / `maxAmountUsd` filters + * and USD sorting (issue #440). `undefined` when the token price was + * unknown at creation — historical rows are never backfilled with + * fabricated values. + */ + usdValueAtCreate?: number; + /** + * Optimistic-concurrency version (issue #405). Starts at 0 on creation and + * is incremented by exactly one on every successful mutation. Exposed to + * HTTP clients as the `ETag` of `GET /api/v1/intents/:id`. + * + * Optional because records constructed before issue #405 (and any caller + * that omits it) predate OCC: repositories treat an absent value as `0`, + * and the Prisma adapter normalises it to the column default on write. + */ + version?: number; + /** + * Whether the user's source-chain deposit has been verified (issue #403). + * Intents stay `open` but are hidden from `GET /intents/open` and cannot be + * accepted until this is true. + * + * Optional with an implicit `false` for records created before issue #403 — + * the Prisma adapter writes the column default when the field is absent. + */ + srcVerified?: boolean; + /** Source-chain transaction that performed the escrow deposit, if supplied. */ + srcTxHash?: string; + /** Details of the most recent source-deposit verification attempt. */ + srcVerification?: SrcVerification; + /** + * Hash of the on-chain transaction a solver-side write is waiting on + * (issue #385). Set together with `pendingOp` when the intent enters a + * pending_* state; cleared once the confirmation watcher resolves it. + */ + pendingTxHash?: string; + /** The operation whose on-chain confirmation is being awaited. */ + pendingOp?: PendingIntentOp; + /** + * Registry token identifiers and decimals (issue #410). Populated by the + * create path when the token is in the registry; intentionally optional so + * the expand/contract migration can land without breaking the in-memory or + * dual-write adapters. + */ + srcTokenId?: string; + dstTokenId?: string; + srcDecimals?: number; + dstDecimals?: number; +} + +/** + * Which state transition is in flight while an intent sits in a pending_* + * state (issue #385). + */ +export type PendingIntentOp = "create" | "accept" | "fill" | "cancel"; + +/** Outcome of the source-chain deposit check for an intent (issue #403). */ +export type SrcVerificationStatus = + | "pending" + | "verified" + | "not_found" + | "mismatch" + | "reorged" + | "skipped" + | "grandfathered"; + +export interface SrcVerification { + status: SrcVerificationStatus; + /** Unix epoch seconds of the last check. */ + checkedAt: number; + /** Block the matching `Deposited` log was found in. */ + blockNumber?: string; + /** Hash of that block — compared on re-checks to detect reorgs. */ + blockHash?: string; + /** Amount the escrow actually received (base units) — may be < srcAmount for fee-on-transfer tokens. */ + receivedAmount?: string; + /** Human-readable reason for a non-verified status. */ + detail?: string; } export interface Quote { diff --git a/src/intents/prisma-intents.repository.ts b/src/intents/prisma-intents.repository.ts index 5bb65ec3..a6989376 100644 --- a/src/intents/prisma-intents.repository.ts +++ b/src/intents/prisma-intents.repository.ts @@ -1,290 +1,294 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { - IIntentsRepository, IdempotentCreateResult, + IIntentsRepository, IntentPatch, + IntentSearchQuery, + IntentSearchResult, MutationResult, VersionConflict, } from "./intents.repository"; -import { Intent, IntentState, TokenInfo, StellarToken } from "./intents.types"; - -// ─── Row → domain mappers ───────────────────────────────────────────────────── - -type IntentRow = { - id: string; - intentId: string; - user: string; - srcChain: string; - srcToken: Prisma.JsonValue; - srcAmount: string; - dstToken: Prisma.JsonValue; - minDstAmount: string; - quotedDstAmount?: string | null; - acceptedDstAmount?: string | null; - solver?: string | null; - state: string; - createdAt: number; - deadline: number; - filledAt?: number | null; - fillAmount?: string | null; - feeAmount?: string | null; - txHash?: string | null; - slashedAt?: number | null; - slashReason?: string | null; - version: number; - idempotencyKey?: string | null; - auction?: Prisma.JsonValue | null; - srcVerified: boolean; - srcTxHash?: string | null; - srcVerification?: Prisma.JsonValue | null; - paramsVersion?: string | null; - // #410 FK columns - srcTokenId?: string | null; - dstTokenId?: string | null; - srcDecimals?: number | null; - dstDecimals?: number | null; -}; - -function rowToIntent(row: IntentRow): Intent { - return { - intentId: row.intentId, - user: row.user, - srcChain: row.srcChain as Intent["srcChain"], - srcToken: row.srcToken as unknown as TokenInfo, - srcAmount: row.srcAmount, - dstToken: row.dstToken as unknown as StellarToken, - minDstAmount: row.minDstAmount, - quotedDstAmount: row.quotedDstAmount ?? undefined, - acceptedDstAmount: row.acceptedDstAmount ?? undefined, - solver: row.solver ?? undefined, - state: row.state as IntentState, - createdAt: row.createdAt, - deadline: row.deadline, - filledAt: row.filledAt ?? undefined, - fillAmount: row.fillAmount ?? undefined, - feeAmount: row.feeAmount ?? undefined, - txHash: row.txHash ?? undefined, - slashedAt: row.slashedAt ?? undefined, - slashReason: row.slashReason ?? undefined, - version: row.version, - auction: row.auction as Record | undefined, - srcVerified: row.srcVerified, - srcTxHash: row.srcTxHash ?? undefined, - srcVerification: row.srcVerification as Intent["srcVerification"], - paramsVersion: row.paramsVersion ?? undefined, - srcTokenId: row.srcTokenId ?? undefined, - dstTokenId: row.dstTokenId ?? undefined, - srcDecimals: row.srcDecimals ?? undefined, - dstDecimals: row.dstDecimals ?? undefined, - }; -} +import { Intent, IntentState, StellarToken, TokenInfo } from "./intents.types"; +import { IntentState as PrismaIntentState, Prisma } from "@prisma/client"; -function intentToCreateData(intent: Intent): Record { - return { - id: intent.intentId, // use intentId as Prisma id for upsert simplicity - intentId: intent.intentId, - user: intent.user, - srcChain: intent.srcChain as string, - srcToken: intent.srcToken as unknown, - srcAmount: intent.srcAmount, - dstToken: intent.dstToken as unknown, - minDstAmount: intent.minDstAmount, - quotedDstAmount: intent.quotedDstAmount ?? null, - acceptedDstAmount: intent.acceptedDstAmount ?? null, - solver: intent.solver ?? null, - state: intent.state as string, - createdAt: intent.createdAt, - deadline: intent.deadline, - filledAt: intent.filledAt ?? null, - fillAmount: intent.fillAmount ?? null, - feeAmount: intent.feeAmount ?? null, - txHash: intent.txHash ?? null, - slashedAt: intent.slashedAt ?? null, - slashReason: intent.slashReason ?? null, - version: intent.version, - idempotencyKey: intent.intentId, // use intentId as idempotency default - auction: intent.auction ?? null, - srcVerified: intent.srcVerified, - srcTxHash: intent.srcTxHash ?? null, - srcVerification: intent.srcVerification ?? null, - paramsVersion: intent.paramsVersion ?? null, - srcTokenId: intent.srcTokenId ?? null, - dstTokenId: intent.dstTokenId ?? null, - srcDecimals: intent.srcDecimals ?? null, - dstDecimals: intent.dstDecimals ?? null, - }; -} - -// ─── Repository ─────────────────────────────────────────────────────────────── +/** PrismaService, or the client handed to a `$transaction` callback (issue #396). */ +export type IntentsPrismaClient = PrismaService | Prisma.TransactionClient; /** - * Prisma-backed implementation of IIntentsRepository (#404 / #405 / #410). + * Prisma-backed implementation of IIntentsRepository. * - * Optimistic concurrency is enforced via `WHERE version = $expected` predicates - * in raw UPDATE statements for all guarded transitions so they remain atomic - * under concurrent load. + * All mutating operations that must be race-free (`acceptIfOpen`, + * `fillIfAccepted`) use a single conditional `updateMany` call so the + * database enforces the state guard atomically — no separate read-then-write. * - * Issue #410: `save()` attempts to populate srcTokenId / dstTokenId / snapshot - * decimals by joining against the tokens table using (address, chain). If the - * token is not in the registry the FK columns are left null — the JSON blob - * path remains valid. + * Bigint amounts (srcAmount, minDstAmount, fillAmount, quotedDstAmount) are + * stored and returned as strings per the project's bigint-as-string convention + * (see CONTRIBUTING.md). JSON columns (srcToken, dstToken) are cast back to + * their TypeScript types on the way out. */ @Injectable() export class PrismaIntentsRepository implements IIntentsRepository { - private readonly logger = new Logger(PrismaIntentsRepository.name); + constructor(private readonly prisma: IntentsPrismaClient) {} + + async save(intent: Intent): Promise { + const data = this.toDbData(intent); + await this.prisma.intent.upsert({ + where: { intentId: intent.intentId }, + create: { ...data, intentId: intent.intentId }, + update: data, + }); + return intent; + } + + /** + * Mirror a row only when the incoming record is strictly newer than what is + * stored (issue #405): a late-arriving out-of-order mirror write must never + * regress a row to an older `version`. A missing row is created; an equal or + * older version is dropped silently. + */ + async saveIfNewer(intent: Intent): Promise { + const data = this.toDbData(intent); + const updated = await this.prisma.intent.updateMany({ + where: { intentId: intent.intentId, version: { lt: intent.version ?? 0 } }, + data, + }); + if (updated.count > 0) return; + const existing = await this.prisma.intent.findUnique({ where: { intentId: intent.intentId } }); + if (existing) return; // stored version is equal or newer — keep it. + try { + await this.prisma.intent.create({ data: { ...data, intentId: intent.intentId } }); + } catch (err) { + // P2002 = a concurrent writer created the row first; their version wins. + if ((err as Prisma.PrismaClientKnownRequestError).code !== "P2002") throw err; + } + } - constructor(private readonly prisma: PrismaService) {} + /** + * Insert `intent` unless an intent created at or after `minCreatedAt` + * already holds `idempotencyKey` (issue #404). Atomic across replicas via + * the unique index on `idempotency_key`; a key held only by an intent older + * than the replay window is released and reused. + */ + async createIdempotent( + intent: Intent, + idempotencyKey: string, + minCreatedAt: number, + ): Promise { + const replay = await this.findByIdempotencyKey(idempotencyKey, minCreatedAt); + if (replay) return { intent: replay, created: false }; - // ── Read methods ────────────────────────────────────────────────────────── + await this.prisma.intent.updateMany({ + where: { idempotencyKey, createdAt: { lt: minCreatedAt } }, + data: { idempotencyKey: null }, + }); + + try { + await this.prisma.intent.create({ + data: { ...this.toDbData(intent), intentId: intent.intentId, idempotencyKey }, + }); + return { intent, created: true }; + } catch (err) { + if ((err as Prisma.PrismaClientKnownRequestError).code === "P2002") { + const winner = await this.findByIdempotencyKey(idempotencyKey, minCreatedAt); + if (winner) return { intent: winner, created: false }; + } + throw err; + } + } + + /** Find the unexpired intent created with `idempotencyKey`, if any. */ + async findByIdempotencyKey(idempotencyKey: string, minCreatedAt: number): Promise { + const row = await this.prisma.intent.findFirst({ + where: { idempotencyKey, createdAt: { gte: minCreatedAt } }, + }); + return row ? this.fromRow(row) : undefined; + } + + /** Fetch several intents in one call; unknown IDs are omitted. */ + async findManyByIds(ids: string[]): Promise { + const unique = [...new Set(ids)]; + if (unique.length === 0) return []; + const rows = await this.prisma.intent.findMany({ where: { intentId: { in: unique } } }); + return rows.map((r) => this.fromRow(r)); + } async findById(id: string): Promise { const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? rowToIntent(row as IntentRow) : undefined; + return row ? this.fromRow(row) : undefined; } async findAll(): Promise { const rows = await this.prisma.intent.findMany({ - orderBy: [{ createdAt: "desc" }, { intentId: "asc" }], + orderBy: { createdAt: "desc" }, }); - return rows.map((r) => rowToIntent(r as IntentRow)); + return rows.map((r) => this.fromRow(r)); } async findByState(state: IntentState): Promise { const rows = await this.prisma.intent.findMany({ - where: { state: state as string }, - orderBy: [{ createdAt: "desc" }, { intentId: "asc" }], + where: { state: this.toPrismaState(state) }, + orderBy: { createdAt: "desc" }, }); - return rows.map((r) => rowToIntent(r as IntentRow)); + return rows.map((r) => this.fromRow(r)); } async findByUser(user: string): Promise { + // Postgres is case-sensitive; normalise the address comparison in-query. const rows = await this.prisma.intent.findMany({ where: { user: { equals: user, mode: "insensitive" } }, - orderBy: [{ createdAt: "desc" }, { intentId: "asc" }], - }); - return rows.map((r) => rowToIntent(r as IntentRow)); - } - - async findManyByIds(ids: string[]): Promise { - if (ids.length === 0) return []; - const unique = [...new Set(ids)]; - const rows = await this.prisma.intent.findMany({ - where: { intentId: { in: unique } }, + orderBy: { createdAt: "desc" }, }); - return rows.map((r) => rowToIntent(r as IntentRow)); + return rows.map((r) => this.fromRow(r)); } + /** Number of intents currently `accepted` by `solver` (issue #405). */ async countAcceptedBySolver(solver: string): Promise { return this.prisma.intent.count({ - where: { solver: { equals: solver, mode: "insensitive" }, state: "accepted" as string }, + where: { state: PrismaIntentState.accepted, solver }, }); } + /** Number of `open` or `accepted` intents owned by `user` (case-insensitive). */ async countActiveByUser(user: string): Promise { return this.prisma.intent.count({ where: { user: { equals: user, mode: "insensitive" }, - state: { in: ["open", "accepted"] as string[] }, + state: { in: [PrismaIntentState.open, PrismaIntentState.accepted] }, }, }); } - findByIdempotencyKey(key: string, minCreatedAt: number): Promise { - return this.prisma.intent - .findFirst({ - where: { idempotencyKey: key, createdAt: { gte: minCreatedAt } }, - }) - .then((row) => (row ? rowToIntent(row as IntentRow) : undefined)); - } + /** + * Advanced search (issue #440) — filtering, sorting and pagination pushed + * into the database query. + * + * Scalar filters use Prisma's typed where input; the JSONB token-symbol + * filters use raw SQL (`lower(token->>'symbol') = lower($1)`) so they can + * use the expression indexes created in the migration and stay + * case-insensitive. Sorting and pagination are applied in the same query so + * no supported combination falls back to a sequential scan + app-side slice. + */ + async search(query: IntentSearchQuery): Promise { + const where: Prisma.IntentWhereInput = {}; + + if (query.state !== undefined) where.state = this.toPrismaState(query.state); + if (query.chain !== undefined) where.srcChain = query.chain as Prisma.IntentWhereInput["srcChain"]; + if (query.user !== undefined) where.user = { equals: query.user, mode: "insensitive" }; + if (query.solver !== undefined) where.solver = { equals: query.solver, mode: "insensitive" }; + + if (query.minAmountUsd !== undefined && query.maxAmountUsd !== undefined) { + where.usdValueAtCreate = { gte: query.minAmountUsd, lte: query.maxAmountUsd }; + } else if (query.minAmountUsd !== undefined) { + where.usdValueAtCreate = { gte: query.minAmountUsd }; + } else if (query.maxAmountUsd !== undefined) { + where.usdValueAtCreate = { lte: query.maxAmountUsd }; + } - // ── Write methods ───────────────────────────────────────────────────────── + if (query.createdFrom !== undefined && query.createdTo !== undefined) { + where.createdAt = { gte: query.createdFrom, lte: query.createdTo }; + } else if (query.createdFrom !== undefined) { + where.createdAt = { gte: query.createdFrom }; + } else if (query.createdTo !== undefined) { + where.createdAt = { lte: query.createdTo }; + } + + // JSONB token-symbol filters — raw SQL so the expression indexes apply. + const raw: Prisma.Sql[] = []; + if (query.srcToken !== undefined) { + raw.push(Prisma.sql`lower(src_token->>'symbol') = lower(${query.srcToken})`); + } + if (query.dstToken !== undefined) { + raw.push(Prisma.sql`lower(dst_token->>'symbol') = lower(${query.dstToken})`); + } + if (raw.length > 0) { + (where as { AND: unknown }).AND = raw; + } + + // Sorting — default createdAt desc (preserves pre-existing behaviour). + const [dimension, direction] = (query.sort ?? "created:desc").split(":"); + const dir = direction === "asc" ? ("asc" as const) : ("desc" as const); + let orderBy: Prisma.IntentOrderByWithRelationInput; + switch (dimension) { + case "deadline": + orderBy = { deadline: dir }; + break; + case "usd": + orderBy = { usdValueAtCreate: dir }; + break; + case "created": + default: + orderBy = { createdAt: dir }; + } + + const offset = query.offset ?? 0; + const limit = query.limit ?? 20; + + const [rows, total] = await Promise.all([ + this.prisma.intent.findMany({ where, orderBy, skip: offset, take: limit }), + this.prisma.intent.count({ where }), + ]); + + return { intents: rows.map((r) => this.fromRow(r)), total }; + } /** - * Save (upsert) an intent. Attempts to resolve token FK columns (#410) - * when `srcTokenId` / `dstTokenId` are not already set. + * Apply `patch` only if the stored version equals `expectedVersion` + * (issue #405): zero rows updated means the intent is absent (`null`) or + * another writer already bumped the version ({@link VersionConflict} with + * the version actually found, so the caller can re-read and retry). */ - async save(intent: Intent): Promise { - const withFk = await this.resolveTokenFks(intent); - const data = intentToCreateData(withFk); - const row = await this.prisma.intent.upsert({ - where: { intentId: intent.intentId }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - create: data as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - update: (({ id: _id, ...rest }) => rest)(data) as any, + async update(id: string, patch: IntentPatch, expectedVersion?: number): Promise { + const result = await this.prisma.intent.updateMany({ + where: { intentId: id, ...(expectedVersion !== undefined ? { version: expectedVersion } : {}) }, + data: { ...this.toDbPatch(patch), version: { increment: 1 } }, }); - return rowToIntent(row as IntentRow); + if (result.count === 0) return this.resolveMiss(id, expectedVersion); + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } /** - * Version-guarded upsert for the dual-write mirror. - * Saves only when incoming version >= stored version. + * Amend the user-adjustable fields of an `open` intent whose existing + * deadline is still in the future (issue #569). The state + deadline + * predicates make the write race-free; `version` is intentionally not + * bumped (an amend widens terms, it does not compete with solver writes). */ - async saveIfNewer(intent: Intent): Promise { - const existing = await this.findById(intent.intentId); - if (existing && existing.version > intent.version) return existing; - return this.save(intent); - } - - async createIdempotent( - intent: Intent, - idempotencyKey: string, - minCreatedAt: number, - ): Promise { - // Check for existing key first (read-before-write is safe here because the - // UNIQUE constraint on idempotency_key makes the INSERT below atomic). - const existing = await this.findByIdempotencyKey(idempotencyKey, minCreatedAt); - if (existing) return { intent: existing, created: false }; - - const withFk = await this.resolveTokenFks(intent); - const data = intentToCreateData(withFk); - // Override the idempotency key with the caller-supplied one. - data.idempotencyKey = idempotencyKey; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const row = await this.prisma.intent.create({ data: data as any }); - return { intent: rowToIntent(row as IntentRow), created: true }; - } catch (err) { - // Unique constraint violation → another replica created first. - if ((err as { code?: string }).code === "P2002") { - const replay = await this.findByIdempotencyKey(idempotencyKey, minCreatedAt); - if (replay) return { intent: replay, created: false }; - } - throw err; - } - } - - async update(id: string, patch: IntentPatch, expectedVersion: number): Promise { - return this.prisma.withDefaultTimeout(async (tx) => { - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return null; - const current = existing as IntentRow; - if (current.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, current.version); - } - const updated = await (tx as unknown as typeof this.prisma).intent.update({ - where: { intentId: id, version: expectedVersion }, - data: { ...(patch as Record), version: expectedVersion + 1 }, - }); - return updated ? rowToIntent(updated as IntentRow) : null; + async amendIfOpen( + id: string, + patch: Pick, + now = Math.floor(Date.now() / 1000), + ): Promise { + const result = await this.prisma.intent.updateMany({ + where: { intentId: id, state: PrismaIntentState.open, deadline: { gt: now } }, + data: { minDstAmount: patch.minDstAmount, deadline: patch.deadline }, }); + if (result.count === 0) return null; + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } async delete(id: string): Promise { try { await this.prisma.intent.delete({ where: { intentId: id } }); return true; - } catch { - return false; + } catch (err) { + if ((err as Prisma.PrismaClientKnownRequestError).code === "P2025") return false; + throw err; } } - // ── Atomic transitions ──────────────────────────────────────────────────── - + /** + * Atomically accept an intent only when it is currently `open` AND its + * deadline is still in the future (issue #473). + * + * Uses a single `updateMany` with a compound WHERE clause so the database + * enforces the state + deadline guards — zero rows updated means another + * solver already won the race or the sweeper already expired the intent. + * + * Lock ordering: callers enforcing per-solver caps must hold the solver + * advisory lock (`pg_advisory_xact_lock`) BEFORE calling this method. + */ async acceptIfOpen( id: string, solver: string, @@ -293,242 +297,386 @@ export class PrismaIntentsRepository implements IIntentsRepository { expectedVersion?: number, ): Promise { const nowSec = now ?? Math.floor(Date.now() / 1000); - return this.prisma.withDefaultTimeout(async (tx) => { - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return null; - const row = existing as IntentRow; - if (row.state !== "open" || row.deadline <= nowSec) return null; - if (expectedVersion !== undefined && row.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, row.version); - } - const updated = await (tx as unknown as typeof this.prisma).intent.updateMany({ - where: { - intentId: id, - state: "open", - deadline: { gt: nowSec }, - version: row.version, - }, - data: { - state: "accepted", - solver, - deadline: newDeadline, - version: row.version + 1, - }, - }); - if (updated.count === 0) return null; - return rowToIntent({ ...row, state: "accepted", solver, deadline: newDeadline, version: row.version + 1 }); + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.open, + deadline: { gt: nowSec }, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { + state: PrismaIntentState.accepted, + solver, + deadline: newDeadline, + version: { increment: 1 }, + }, }); - } - async acceptIfOpenWithinExposure( - id: string, - solver: string, - newDeadline: number, - now: number, - candidateExposureUsdMicros: bigint, - maxExposureUsdMicros: bigint, - ): Promise<{ intent: Intent | null; exposureExceeded: boolean }> { - // Serialise per-solver using a Postgres advisory lock so only one - // concurrent request can evaluate the exposure cap for a given solver. - const lockKey = this.solverAdvisoryLockKey(solver); - return this.prisma.withDefaultTimeout(async (tx) => { - const rawTx = tx as unknown as { $executeRaw: typeof this.prisma.$executeRaw }; - await rawTx.$executeRaw`SELECT pg_advisory_xact_lock(${lockKey})`; - - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return { intent: null, exposureExceeded: false }; - const row = existing as IntentRow; - if (row.state !== "open" || row.deadline <= now) { - return { intent: null, exposureExceeded: false }; - } + if (result.count === 0) return this.resolveMiss(id, expectedVersion); - // Sum exposure for accepted intents belonging to this solver. - // srcAmount is TEXT — cast to NUMERIC for SUM via raw query. - const rawResult = await (tx as unknown as typeof this.prisma).$queryRaw>` - SELECT COALESCE(SUM(src_amount::numeric)::text, '0') AS total - FROM intents - WHERE LOWER(solver) = LOWER(${solver}) - AND state = 'accepted' - AND deadline > ${now} - `; - const acceptedExposure = BigInt(rawResult[0]?.total ?? "0"); - - if (acceptedExposure + candidateExposureUsdMicros > maxExposureUsdMicros) { - return { intent: null, exposureExceeded: true }; - } + // Fetch the updated row to return the full intent shape. + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; + } - const updated = await (tx as unknown as typeof this.prisma).intent.updateMany({ - where: { intentId: id, state: "open", version: row.version }, - data: { state: "accepted", solver, deadline: newDeadline, version: row.version + 1 }, - }); - if (updated.count === 0) return { intent: null, exposureExceeded: false }; - return { - intent: rowToIntent({ ...row, state: "accepted", solver, deadline: newDeadline, version: row.version + 1 }), - exposureExceeded: false, - }; + /** + * Acquire a transaction-scoped advisory lock for a solver key (issue #473). + * + * Must be called inside a `$transaction` callback to serialize per-solver + * cap checks across replicas. Lock ordering: solver lock BEFORE any intent + * row write, released automatically at transaction end. No-op fallback when + * the Prisma client does not expose `$executeRaw` (e.g. unit tests). + */ + async acquireSolverLock(solver: string): Promise { + const client = this.prisma as unknown as { + $executeRaw?: (q: TemplateStringsArray, ...v: unknown[]) => Promise; + }; + if (typeof client.$executeRaw !== "function") return; + await client.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${solver}))`; + } + + /** Count open/accepted intents for a user with a single COUNT query. */ + async countOpenByUser(user: string): Promise { + return this.prisma.intent.count({ + where: { + user: { equals: user, mode: "insensitive" }, + state: { in: [PrismaIntentState.open, PrismaIntentState.accepted] }, + }, }); } + /** + * Atomically fill an intent only when it is currently `accepted` by the + * specified solver AND the fill window has not elapsed (issue #473). + * + * Uses a single `updateMany` with a compound WHERE clause — zero rows + * updated means the intent was not in the expected state, is assigned to a + * different solver, or the deadline passed (sweeper wins). + */ async fillIfAccepted( id: string, solver: string, - patch: Pick, "filledAt" | "fillAmount" | "feeAmount" | "txHash">, + patch: Omit, "state" | "solver">, now?: number, expectedVersion?: number, ): Promise { const nowSec = now ?? Math.floor(Date.now() / 1000); - return this.prisma.withDefaultTimeout(async (tx) => { - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return null; - const row = existing as IntentRow; - if (row.state !== "accepted" || row.solver !== solver || row.deadline <= nowSec) return null; - if (expectedVersion !== undefined && row.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, row.version); - } - const updated = await (tx as unknown as typeof this.prisma).intent.updateMany({ - where: { intentId: id, state: "accepted", solver, version: row.version, deadline: { gt: nowSec } }, - data: { ...patch, state: "filled", version: row.version + 1 }, - }); - if (updated.count === 0) return null; - return rowToIntent({ ...row, ...patch, state: "filled", version: row.version + 1 }); + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.accepted, + solver, + deadline: { gt: nowSec }, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { + state: PrismaIntentState.filled, + version: { increment: 1 }, + ...(patch.filledAt !== undefined ? { filledAt: patch.filledAt } : {}), + ...(patch.fillAmount !== undefined ? { fillAmount: patch.fillAmount } : {}), + ...(patch.feeAmount !== undefined + ? { feeAmount: patch.feeAmount as string } + : {}), + ...(patch.txHash !== undefined ? { txHash: patch.txHash } : {}), + }, }); + + if (result.count === 0) return this.resolveMiss(id, expectedVersion); + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } - async cancelIfOpen(id: string, expectedVersion?: number): Promise { - return this.conditionalTransition(id, "open", "cancelled", {}, expectedVersion); + /** + * Atomically claims a tx hash for one accepted intent. The unique index on + * intents.tx_hash arbitrates cross-intent races; the row predicate arbitrates + * concurrent attempts to replace a hash on the same intent. + */ + async reserveFillTxHash(id: string, solver: string, txHash: string): Promise { + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.accepted, + solver, + OR: [{ txHash: null }, { txHash }], + }, + data: { + txHash, + fillVerificationState: "pending", + fillVerificationReason: null, + }, + }); + if (result.count === 0) return null; + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } - async expireIfOpen(id: string, expectedVersion?: number): Promise { - return this.conditionalTransition(id, "open", "expired", {}, expectedVersion); + /** + * Atomically cancel an intent only when it is currently `open`. Guards + * against a concurrent solver accept() or sweeper expiry on the same intent. + */ + async cancelIfOpen(id: string, expectedVersion?: number): Promise { + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.open, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { state: PrismaIntentState.cancelled, version: { increment: 1 } }, + }); + + if (result.count === 0) return this.resolveMiss(id, expectedVersion); + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } - async extendDeadlineIfAccepted( - id: string, - newDeadline: number, - expectedVersion?: number, - ): Promise { - return this.prisma.withDefaultTimeout(async (tx) => { - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return null; - const row = existing as IntentRow; - if (row.state !== "accepted" || row.deadline >= newDeadline) return null; - if (expectedVersion !== undefined && row.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, row.version); - } - const updated = await (tx as unknown as typeof this.prisma).intent.updateMany({ - where: { intentId: id, state: "accepted", version: row.version, deadline: { lt: newDeadline } }, - data: { deadline: newDeadline, version: row.version + 1 }, - }); - if (updated.count === 0) return null; - return rowToIntent({ ...row, deadline: newDeadline, version: row.version + 1 }); + /** + * Atomically expire an intent only when it is currently `open`. Used by the + * sweeper so a concurrent user cancel() or solver accept() always wins the race. + */ + async expireIfOpen(id: string, expectedVersion?: number): Promise { + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.open, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { state: PrismaIntentState.expired, version: { increment: 1 } }, }); + + if (result.count === 0) return this.resolveMiss(id, expectedVersion); + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } + /** + * Atomically slash an intent only when it is currently `accepted`. Used by + * the sweeper so a concurrent solver fill() always wins the race. + */ async slashIfAccepted( id: string, patch: { slashedAt: number; slashReason: string }, expectedVersion?: number, ): Promise { - return this.conditionalTransition(id, "accepted", "slashed", patch, expectedVersion); - } + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.accepted, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { + state: PrismaIntentState.slashed, + version: { increment: 1 }, + slashedAt: patch.slashedAt, + slashReason: patch.slashReason, + }, + }); + + if (result.count === 0) return this.resolveMiss(id, expectedVersion); - // ── Private helpers ─────────────────────────────────────────────────────── + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; + } /** - * Generic conditional-transition helper for simple state guards. + * Issue #477 — push an accepted intent's deadline out during a fill pause. + * The `deadline < newDeadline` predicate makes this a no-op once the window + * is already long enough, so repeated sweep cycles cannot creep the deadline + * forward indefinitely. */ - private async conditionalTransition( + async extendDeadlineIfAccepted( id: string, - fromState: IntentState, - toState: IntentState, - extra: Record, + newDeadline: number, expectedVersion?: number, ): Promise { - return this.prisma.withDefaultTimeout(async (tx) => { - const existing = await (tx as unknown as typeof this.prisma).intent.findUnique({ - where: { intentId: id }, - }); - if (!existing) return null; - const row = existing as IntentRow; - if (row.state !== fromState) return null; - if (expectedVersion !== undefined && row.version !== expectedVersion) { - return new VersionConflict(id, expectedVersion, row.version); - } - const updated = await (tx as unknown as typeof this.prisma).intent.updateMany({ - where: { intentId: id, state: fromState, version: row.version }, - data: { ...extra, state: toState, version: row.version + 1 }, - }); - if (updated.count === 0) return null; - return rowToIntent({ ...row, ...extra, state: toState, version: row.version + 1 }); + const result = await this.prisma.intent.updateMany({ + where: { + intentId: id, + state: PrismaIntentState.accepted, + deadline: { lt: newDeadline }, + ...(expectedVersion !== undefined ? { version: expectedVersion } : {}), + }, + data: { deadline: newDeadline, version: { increment: 1 } }, }); + + if (result.count === 0) return this.resolveMiss(id, expectedVersion); + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; } + // ── Private helpers ──────────────────────────────────────────────────────── + /** - * Resolve src/dst token FK columns from the tokens table (#410). - * Returns the intent unchanged if either token is not in the registry. + * Classify a zero-row conditional update (issue #405): a stale + * `expectedVersion` becomes a {@link VersionConflict} carrying the version + * actually found so callers can re-read and retry; anything else (absent row + * or failed state guard) is `null`. Mirrors the in-memory adapter's + * ordering — the version check is reported before the state guard. */ - private async resolveTokenFks(intent: Intent): Promise { - // Skip if already resolved. - if (intent.srcTokenId && intent.dstTokenId) return intent; + private async resolveMiss(id: string, expectedVersion?: number): Promise { + if (expectedVersion === undefined) return null; + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + if (row && row.version !== expectedVersion) { + return new VersionConflict(id, expectedVersion, row.version); + } + return null; + } - try { - const [srcToken, dstToken] = await Promise.all([ - !intent.srcTokenId - ? this.prisma.token.findUnique({ - where: { - address_chain: { - address: intent.srcToken.address, - chain: intent.srcChain as string, - }, - }, - select: { id: true, decimals: true }, - }) - : null, - !intent.dstTokenId - ? this.prisma.token.findUnique({ - where: { - address_chain: { - address: intent.dstToken.contract, - chain: "stellar" as string, - }, - }, - select: { id: true, decimals: true }, - }) - : null, - ]); - - return { - ...intent, - srcTokenId: intent.srcTokenId ?? srcToken?.id ?? undefined, - dstTokenId: intent.dstTokenId ?? dstToken?.id ?? undefined, - srcDecimals: intent.srcDecimals ?? srcToken?.decimals ?? undefined, - dstDecimals: intent.dstDecimals ?? dstToken?.decimals ?? undefined, - }; - } catch (err) { - this.logger.warn(`[#410] Failed to resolve token FKs for intent ${intent.intentId}: ${(err as Error).message}`); - return intent; + /** Map Intent → Prisma create/update data (omits intentId which is the key). */ + private toDbData( + intent: Intent, + ): Omit { + const data: Omit & { feeAmount?: string | null } = { + user: intent.user, + srcChain: intent.srcChain as Prisma.IntentCreateInput["srcChain"], + srcToken: intent.srcToken as unknown as Prisma.InputJsonValue, + srcAmount: intent.srcAmount, + dstToken: intent.dstToken as unknown as Prisma.InputJsonValue, + minDstAmount: intent.minDstAmount, + ...(intent.auction ? { auction: intent.auction as unknown as Prisma.InputJsonValue } : {}), + acceptedDstAmount: intent.acceptedDstAmount ?? null, + quotedDstAmount: intent.quotedDstAmount ?? null, + solver: intent.solver ?? null, + state: this.toPrismaState(intent.state), + createdAt: intent.createdAt, + deadline: intent.deadline, + filledAt: intent.filledAt ?? null, + fillAmount: intent.fillAmount ?? null, + txHash: intent.txHash ?? null, + ...(intent.usdValueAtCreate !== undefined + ? { usdValueAtCreate: intent.usdValueAtCreate } + : {}), + fillVerificationState: intent.fillVerificationState ?? null, + fillVerificationReason: intent.fillVerificationReason ?? null, + fillVerifiedAt: intent.fillVerifiedAt ? new Date(intent.fillVerifiedAt) : null, + version: intent.version ?? 0, + srcVerified: intent.srcVerified ?? false, + srcTxHash: intent.srcTxHash ?? null, + srcVerification: (intent.srcVerification ?? null) as unknown as Prisma.InputJsonValue, + ...(intent.slashedAt !== undefined ? { slashedAt: intent.slashedAt } : {}), + ...(intent.slashReason !== undefined ? { slashReason: intent.slashReason } : {}), + }; + + if (intent.feeAmount !== undefined) { + (data as { feeAmount?: string | null }).feeAmount = intent.feeAmount ?? null; } + + return data; } - /** - * Convert a solver address string to a 64-bit integer for use as a Postgres - * advisory lock key (per-solver serialization of exposure-cap check). - * - * We use a simple hash so the lock key stays within int8 range. - */ - private solverAdvisoryLockKey(solver: string): bigint { - let hash = 0n; - for (let i = 0; i < solver.length; i++) { - hash = (hash * 31n + BigInt(solver.charCodeAt(i))) & 0x7FFFFFFFFFFFFFFFn; + /** Build an `updateMany`-compatible data object from a partial Intent patch. */ + private toDbPatch(patch: Partial): Prisma.IntentUpdateInput { + const data = {} as Prisma.IntentUpdateInput & { feeAmount?: string | null }; + if (patch.state !== undefined) data.state = this.toPrismaState(patch.state); + if (patch.solver !== undefined) data.solver = patch.solver; + if (patch.deadline !== undefined) data.deadline = patch.deadline; + if (patch.filledAt !== undefined) data.filledAt = patch.filledAt; + if (patch.fillAmount !== undefined) data.fillAmount = patch.fillAmount; + if (patch.feeAmount !== undefined) (data as { feeAmount?: string | null }).feeAmount = patch.feeAmount ?? null; + if (patch.txHash !== undefined) data.txHash = patch.txHash; + if (patch.fillVerificationState !== undefined) data.fillVerificationState = patch.fillVerificationState; + if (patch.fillVerificationReason !== undefined) data.fillVerificationReason = patch.fillVerificationReason; + if (patch.fillVerifiedAt !== undefined) data.fillVerifiedAt = patch.fillVerifiedAt ? new Date(patch.fillVerifiedAt) : null; + if (patch.quotedDstAmount !== undefined) data.quotedDstAmount = patch.quotedDstAmount; + if (patch.srcAmount !== undefined) data.srcAmount = patch.srcAmount; + if (patch.minDstAmount !== undefined) data.minDstAmount = patch.minDstAmount; + if (patch.srcVerified !== undefined) data.srcVerified = patch.srcVerified; + if (patch.srcTxHash !== undefined) data.srcTxHash = patch.srcTxHash; + if (patch.srcVerification !== undefined) { + (data as Prisma.IntentUpdateInput & { srcVerification?: Prisma.InputJsonValue | null }).srcVerification = + (patch.srcVerification ?? null) as unknown as Prisma.InputJsonValue; } - return hash; + if (patch.usdValueAtCreate !== undefined) data.usdValueAtCreate = patch.usdValueAtCreate; + if (patch.auction !== undefined) { + (data as Prisma.IntentUpdateInput & { auction?: Prisma.InputJsonValue }).auction = + patch.auction as unknown as Prisma.InputJsonValue; + } + if (patch.acceptedDstAmount !== undefined) { + (data as Prisma.IntentUpdateInput & { acceptedDstAmount?: string | null }).acceptedDstAmount = + patch.acceptedDstAmount ?? null; + } + if (patch.slashedAt !== undefined) data.slashedAt = patch.slashedAt; + if (patch.slashReason !== undefined) data.slashReason = patch.slashReason; + return data; + } + + /** Map a Prisma Intent row → domain Intent. */ + private fromRow(row: { + intentId: string; + user: string; + srcChain: string; + srcToken: Prisma.JsonValue; + srcAmount: string; + dstToken: Prisma.JsonValue; + minDstAmount: string; + auction: Prisma.JsonValue | null; + acceptedDstAmount: string | null; + quotedDstAmount: string | null; + solver: string | null; + state: PrismaIntentState; + createdAt: number; + deadline: number; + filledAt: number | null; + fillAmount: string | null; + feeAmount?: string | null; + txHash: string | null; + usdValueAtCreate?: number | null; + /** Prisma maps this to a plain string column; narrowed on return. */ + fillVerificationState?: string | null; + fillVerificationReason?: string | null; + fillVerifiedAt?: Date | null; + version: number; + srcVerified: boolean; + srcTxHash: string | null; + srcVerification: Prisma.JsonValue | null; + slashedAt?: number | null; + slashReason?: string | null; + }): Intent { + return { + intentId: row.intentId, + user: row.user, + srcChain: row.srcChain as Intent["srcChain"], + srcToken: row.srcToken as unknown as TokenInfo, + srcAmount: row.srcAmount, + dstToken: row.dstToken as unknown as StellarToken, + minDstAmount: row.minDstAmount, + ...(row.auction !== null ? { auction: row.auction as unknown as Intent["auction"] } : {}), + ...(row.acceptedDstAmount !== null ? { acceptedDstAmount: row.acceptedDstAmount } : {}), + ...(row.quotedDstAmount !== null ? { quotedDstAmount: row.quotedDstAmount } : {}), + ...(row.solver !== null ? { solver: row.solver } : {}), + state: row.state as IntentState, + createdAt: row.createdAt, + deadline: row.deadline, + ...(row.filledAt !== null ? { filledAt: row.filledAt } : {}), + ...(row.fillAmount !== null ? { fillAmount: row.fillAmount } : {}), + ...(row.feeAmount !== undefined && row.feeAmount !== null ? { feeAmount: row.feeAmount } : {}), + ...(row.txHash !== null ? { txHash: row.txHash } : {}), + ...(row.usdValueAtCreate !== null && row.usdValueAtCreate !== undefined + ? { usdValueAtCreate: row.usdValueAtCreate } + : {}), + ...(row.fillVerificationState + ? { + fillVerificationState: + row.fillVerificationState as Intent["fillVerificationState"], + } + : {}), + ...(row.fillVerificationReason ? { fillVerificationReason: row.fillVerificationReason } : {}), + ...(row.fillVerifiedAt ? { fillVerifiedAt: row.fillVerifiedAt.toISOString() } : {}), + version: row.version, + srcVerified: row.srcVerified, + ...(row.srcTxHash !== null ? { srcTxHash: row.srcTxHash } : {}), + ...(row.srcVerification !== null + ? { srcVerification: row.srcVerification as unknown as Intent["srcVerification"] } + : {}), + ...(row.slashedAt !== null && row.slashedAt !== undefined ? { slashedAt: row.slashedAt } : {}), + ...(row.slashReason !== null && row.slashReason !== undefined ? { slashReason: row.slashReason } : {}), + }; + } + + private toPrismaState(state: IntentState): PrismaIntentState { + return state as PrismaIntentState; } } diff --git a/src/intents/slashing-pipeline.service.spec.ts b/src/intents/slashing-pipeline.service.spec.ts index ee9abac8..a3080c15 100644 --- a/src/intents/slashing-pipeline.service.spec.ts +++ b/src/intents/slashing-pipeline.service.spec.ts @@ -78,8 +78,6 @@ describe("SlashingPipelineService (#397)", () => { { get: jest.fn().mockReturnValue(false) } as unknown as ConfigService, {} as StellarTxService, { intentAuditLog: { create: jest.fn().mockResolvedValue({}) } } as unknown as PrismaService, - undefined, - undefined, { snapshotForChain: jest.fn().mockReturnValue({ version: 0, feeBps: 30, deadlineSeconds: 1800, fillWindowSeconds: 600 }), } as unknown as ProtocolParamsService, diff --git a/src/intents/source-deposit-verification.service.spec.ts b/src/intents/source-deposit-verification.service.spec.ts index 293100a4..71b33008 100644 --- a/src/intents/source-deposit-verification.service.spec.ts +++ b/src/intents/source-deposit-verification.service.spec.ts @@ -41,8 +41,6 @@ function build(enabled = true) { config(enabled), {} as StellarTxService, { intentAuditLog: { create: jest.fn().mockResolvedValue({}) } } as unknown as PrismaService, - undefined, // shadow monitor - undefined, // metrics { snapshotForChain: () => ({ version: 0, deadlineSeconds: 1800, fillWindowSeconds: 600 }) } as unknown as ProtocolParamsService, ); const verify = jest.fn, [unknown]>(); @@ -117,7 +115,7 @@ describe("SourceDepositVerificationService (issue #403)", () => { const stored = (await h.intents.get(created.intentId))!; expect(stored).toMatchObject({ srcVerified: true, - version: created.version + 1, + version: (created.version ?? 0) + 1, srcVerification: { status: "verified", checkedAt: Math.floor(T0 / 1000), diff --git a/src/intents/state-machine.ts b/src/intents/state-machine.ts index c5d6d382..8d58a58b 100644 --- a/src/intents/state-machine.ts +++ b/src/intents/state-machine.ts @@ -9,6 +9,14 @@ import { IntentState } from "./intents.types"; * repositories must route mutations through {@link canTransition} (or one of * the guarded `*If*` repository methods) instead of blind `update()` writes. * + * Exception — pending markers (issue #385): the `pending_*` states are a + * confirmation layer stacked on top of the lifecycle, not steps of it. + * `IntentsService.transitionToOnChainPending` parks an intent there with a + * direct write (the guarded `*If*` edge it mirrors has already committed — + * even `filled → pending_filled`, which would be illegal as a lifecycle + * edge), and `IntentsService.confirmIntent` settles it back through the + * `pending_* → base` edges below. + * * ``` * ┌──────┐ acceptIfOpen ┌──────────┐ fillIfAccepted ┌────────┐ * │ open ├────────────────►│ accepted ├────────────────►│ filled │ (terminal) @@ -30,12 +38,18 @@ import { IntentState } from "./intents.types"; * `slashed` are terminal and have no outgoing edges. */ export const TRANSITIONS: Readonly> = { - open: ["accepted", "cancelled", "expired"], - accepted: ["filled", "slashed"], + open: ["accepted", "cancelled", "expired", "pending_open", "pending_accepted", "pending_cancelled"], + accepted: ["filled", "slashed", "pending_accepted", "pending_filled", "pending_cancelled"], + // Terminal states stay sinks — parking out of them (`filled → pending_filled`) + // is a direct confirmation write, not a lifecycle edge (see the class docs). filled: [], cancelled: [], expired: [], slashed: [], + pending_open: ["open"], + pending_accepted: ["accepted"], + pending_filled: ["filled"], + pending_cancelled: ["cancelled"], }; /** States from which no further transition is legal. */ diff --git a/src/intents/ws/connection-state.ts b/src/intents/ws/connection-state.ts index e69de29b..220e311a 100644 --- a/src/intents/ws/connection-state.ts +++ b/src/intents/ws/connection-state.ts @@ -0,0 +1,128 @@ +import type { WebSocket } from "ws"; + +/** + * Encoding format negotiated per WebSocket connection (Activity 1). + */ +export type EncodingFormat = "json" | "msgpack"; + +/** Classic token bucket: `ratePerSec` sustained, up to `burst` at once. */ +export class TokenBucket { + private tokens: number; + private last: number; + + constructor( + private readonly ratePerSec: number, + private readonly burst: number, + now = Date.now(), + ) { + this.tokens = burst; + this.last = now; + } + + /** Consumes one token; false when the bucket is empty. */ + take(now = Date.now()): boolean { + this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec); + this.last = now; + if (this.tokens < 1) return false; + this.tokens -= 1; + return true; + } +} + +/** + * Client IP for per-IP limits. With `trustedHops = 0` the socket address is + * used and X-Forwarded-For is ignored (clients cannot spoof it). With N + * trusted proxies, the address N entries from the right of + * `X-Forwarded-For, remoteAddress` is the one the outermost trusted proxy saw. + */ +export function resolveClientIp( + remoteAddress: string | undefined, + forwardedFor: string | string[] | undefined, + trustedHops: number, +): string { + const socketIp = remoteAddress ?? "unknown"; + if (trustedHops <= 0 || !forwardedFor) return socketIp; + const header = Array.isArray(forwardedFor) ? forwardedFor.join(",") : forwardedFor; + const chain = [...header.split(",").map((s) => s.trim()).filter(Boolean), socketIp]; + return chain[Math.max(0, chain.length - 1 - trustedHops)]; +} + +export interface OutboundLimits { + queueMax: number; + bufferBytes: number; + policy: "drop_oldest" | "disconnect"; +} + +export type OutboundResult = "sent" | "queued" | "dropped_oldest" | "disconnected"; + +/** + * Per-connection state (issue #455): identity, inbound token bucket and a + * bounded outbound queue. + * + * Messages go straight to the socket while `bufferedAmount` is below + * `bufferBytes`; above it they wait in a queue of at most `queueMax` + * messages, flushed from the `send` callbacks as the socket drains. When the + * queue is full the policy either drops the oldest queued message or + * terminates the connection — so a slow consumer costs at most + * `bufferBytes + queueMax` messages of memory. + */ +export class ConnectionState { + readonly bucket: TokenBucket; + violations = 0; + /** Authenticated solver address (signature or JWT), if any. */ + identity: string | null = null; + /** Encoding format negotiated during handshake (Activity 1). */ + encoding: EncodingFormat = "json"; + private readonly queue: Array = []; + private closed = false; + + constructor( + private readonly socket: WebSocket, + readonly ip: string, + rate: { perSec: number; burst: number }, + private readonly limits: OutboundLimits, + ) { + this.bucket = new TokenBucket(rate.perSec, rate.burst); + } + + queued(): number { + return this.queue.length; + } + + send(payload: string | Uint8Array): OutboundResult { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return "sent"; + if (this.queue.length === 0 && this.socket.bufferedAmount < this.limits.bufferBytes) { + this.write(payload); + return "sent"; + } + this.queue.push(payload); + if (this.queue.length <= this.limits.queueMax) return "queued"; + if (this.limits.policy === "disconnect") { + this.close(); + this.socket.terminate(); + return "disconnected"; + } + this.queue.shift(); + return "dropped_oldest"; + } + + close(): void { + this.closed = true; + this.queue.length = 0; + } + + private write(payload: string | Uint8Array) { + this.socket.send(payload, () => this.flush()); + } + + private flush() { + while ( + !this.closed && + this.queue.length > 0 && + this.socket.readyState === this.socket.OPEN && + this.socket.bufferedAmount < this.limits.bufferBytes + ) { + this.write(this.queue.shift()!); + } + } +} diff --git a/src/metrics/metrics.controller.ts b/src/metrics/metrics.controller.ts index e69de29b..14763583 100644 --- a/src/metrics/metrics.controller.ts +++ b/src/metrics/metrics.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Header, Inject, UseGuards } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { MetricsService } from "./metrics.service"; +import { MetricsTokenGuard } from "./metrics-token.guard"; + +@ApiTags("metrics") +@Controller("metrics") +export class MetricsController { + constructor(@Inject(MetricsService) private readonly metricsService: MetricsService) {} + + /** + * Prometheus scrape endpoint. + * + * Access is controlled by MetricsTokenGuard: + * - With METRICS_TOKEN set: require `Authorization: Bearer `. + * - Without METRICS_TOKEN in non-production: open (local dev / test). + * - Without METRICS_TOKEN in production: always 401 (fail closed). + * + * Configure your Prometheus scrape job with: + * authorization: + * type: Bearer + * credentials: + * + * Closes #298. + */ + @Get() + @UseGuards(MetricsTokenGuard) + @Header("Content-Type", "text/plain; charset=utf-8") + async index(): Promise { + return this.metricsService.metrics(); + } +} diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index a6a24d91..b4b428af 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -1,1031 +1,909 @@ -import { Injectable, OnModuleInit } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import client from "prom-client"; -import { AppConfig } from "../config/configuration"; - -@Injectable() -export class MetricsService implements OnModuleInit { - private readonly register: client.Registry; - - // ── HTTP ─────────────────────────────────────────────────────────────────── - public readonly httpRequestDuration: client.Histogram; - public readonly httpRequestTotal: client.Counter; - public readonly httpRequestErrors: client.Counter; - - // ── Intent / WS general ─────────────────────────────────────────────────── - public readonly intentStateTransitions: client.Counter; - public readonly wsConnections: client.Gauge; - public readonly intentCreateDuration: client.Histogram; - public readonly wsDeliveryDuration: client.Histogram; - public readonly eventIngestionLag: client.Gauge; - - /** - * Shadow-mode divergence monitor (issue #401). - * - * `vortex_shadow_comparisons_total{transition,outcome}` counts every - * (expected, simulated) pair the monitor resolved, and - * `vortex_shadow_divergences_total{transition,reason}` counts the subset the - * classifier flagged. `vortex_shadow_dropped_total` and - * `vortex_shadow_queue_depth` expose monitor health so a starved monitor is - * never mistaken for a healthy one — the on-chain cutover runbook's go/no-go - * threshold is only meaningful while these are being exercised. - */ - public readonly shadowComparisons: client.Counter; - public readonly shadowDivergences: client.Counter; - public readonly shadowDropped: client.Counter; - public readonly shadowQueueDepth: client.Gauge; - - /** - * Leader election metrics (issue #493). - * Track which replica is leader per worker and how often leadership changes. - */ - public readonly leaderElectionIsLeader: client.Gauge; - public readonly leaderElectionChangesTotal: client.Counter; - - /** Background job metrics (issue #494). */ - public readonly jobsQueueDepth: client.Gauge; - public readonly jobsDuration: client.Histogram; - public readonly jobsFailures: client.Counter; - public readonly jobsDeadLettered: client.Counter; - private queueDepthProvider?: () => Promise>; - - /** Feature-flag evaluations (issue #495). */ - public readonly flagEvaluations: client.Counter; - - /** - * Sweeper metrics — these replace the retired src/common/metrics.ts - * MetricsRegistry.sweeper namespace (see issue #259). - * - * The on-call runbook (docs/runbooks/on-call.md) references these names - * directly. Any change here must be reflected there. - */ - public readonly sweeperExpiredTotal: client.Counter; - public readonly sweeperSweepDurationMs: client.Histogram; - - // ── SLO SLIs (issue #480) ───────────────────────────────────────────────── - public readonly txConfirmationDuration: client.Histogram; - - // ── WS capability-filter metrics (issue #436) ──────────────────────────── - /** - * WS events delivered to an authenticated solver after capability filtering. - * Label `solver` is truncated to 12 chars to bound Prometheus label cardinality. - */ - public readonly wsEventsDeliveredTotal: client.Counter; - /** - * WS events suppressed by the capability filter (intent outside solver's - * supported chains/tokens or solver bond = 0). - */ - public readonly wsEventsFilteredTotal: client.Counter; - - // ── Restore-transaction metrics (issue #394) ───────────────────────────── - public readonly sorobanRestoreTotal: client.Counter; - public readonly sorobanRestoreFeeStroops: client.Histogram; - - // ── Remote signer call latency (issue #400) ─────────────────────────────── - public readonly signerCallDurationSeconds: client.Histogram; - - // ── Solver-registry event ingestion (issue #399) ────────────────────────── - public readonly solverRegistryEventsTotal: client.Counter; - - // ── Anti-griefing controls (issue #453) ────────────────────────────────── - /** - * `vortex_griefing_state_transitions_total{solver,from_state,to_state}` — counts - * every enforcement escalation/recovery for a solver. Solver label is - * truncated to 12 chars to bound cardinality. - */ - public readonly griefingStateTransitionsTotal: client.Counter; - /** - * `vortex_griefing_unfilled_ratio{solver}` — current rolling unfilled/accept - * ratio per solver under enforcement (Gauge, updated on each unfilled event). - */ - public readonly griefingUnfilledRatio: client.Gauge; - /** - * `vortex_griefing_enforced_solvers` — number of solvers currently NOT in - * the "ok" state. - */ - public readonly griefingEnforcedSolvers: client.Gauge; - /** - * `vortex_griefing_enforcement_state{solver,state}` — current enforcement - * state as a 0/1 gauge per (solver, state) label pair. Allows dashboards and - * alerts to query "how many solvers are suspended right now" with a simple - * `sum(vortex_griefing_enforcement_state{state="suspended"})`. - */ - public readonly griefingEnforcementState: client.Gauge; - /** - * `vortex_griefing_concurrency_limit{solver}` — effective concurrent-accept - * cap while in "reduced-concurrency" state; 0 when no limit is active. - */ - public readonly griefingConcurrencyLimit: client.Gauge; - - constructor(private readonly configService: ConfigService) { - this.register = new client.Registry(); - const prefix = "vortex_"; - - this.httpRequestDuration = new client.Histogram({ - name: `${prefix}http_request_duration_seconds`, - help: "HTTP request duration in seconds", - labelNames: ["method", "route", "status_code"], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], - registers: [this.register], - }); - - this.httpRequestTotal = new client.Counter({ - name: `${prefix}http_requests_total`, - help: "Total number of HTTP requests", - labelNames: ["method", "route", "status_code"], - registers: [this.register], - }); - - this.httpRequestErrors = new client.Counter({ - name: `${prefix}http_request_errors_total`, - help: "Total number of HTTP request errors (5xx)", - labelNames: ["method", "route", "status_code"], - registers: [this.register], - }); - - this.intentStateTransitions = new client.Counter({ - name: `${prefix}intent_state_transitions_total`, - help: "Total number of intent state transitions", - labelNames: ["from_state", "to_state"], - registers: [this.register], - }); - - this.wsConnections = new client.Gauge({ - name: `${prefix}ws_connections_active`, - help: "Number of active WebSocket connections", - registers: [this.register], - }); - - // ── Sweeper metrics (issue #259) ───────────────────────────────────────── - this.sweeperExpiredTotal = new client.Counter({ - name: `${prefix}sweeper_expired_total`, - help: "Total number of intents expired across all sweeps", - registers: [this.register], - }); - - this.sweeperSweepDurationMs = new client.Histogram({ - name: `${prefix}sweeper_sweep_duration_ms`, - help: "Duration of each IntentsSweeperService.sweep() execution in milliseconds", - buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000], - registers: [this.register], - }); - - // ── SLO SLIs (issue #480) ─────────────────────────────────────────────── - this.intentCreateDuration = new client.Histogram({ - name: `${prefix}intent_create_duration_seconds`, - help: "Intent-create handler latency in seconds", - labelNames: ["route"], - buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - this.wsDeliveryDuration = new client.Histogram({ - name: `${prefix}ws_delivery_duration_seconds`, - help: "WS end-to-end delivery latency (broadcast to send) in seconds", - buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - this.eventIngestionLag = new client.Gauge({ - name: `${prefix}event_ingestion_lag_seconds`, - help: "Event-ingestion lag: now minus newest ingested event timestamp", - registers: [this.register], - }); - - this.txConfirmationDuration = new client.Histogram({ - name: `${prefix}tx_confirmation_duration_seconds`, - help: "Fill submission to on-chain confirmation latency in seconds", - buckets: [1, 5, 15, 30, 60, 120, 300], - registers: [this.register], - }); - - // ── WS capability-filter metrics (issue #436) ────────────────────────── - this.wsEventsDeliveredTotal = new client.Counter({ - name: `${prefix}ws_events_delivered_total`, - help: "WS events delivered to authenticated solvers after capability filtering", - labelNames: ["solver"], - registers: [this.register], - }); - - this.wsEventsFilteredTotal = new client.Counter({ - name: `${prefix}ws_events_filtered_total`, - help: "WS events suppressed by capability filter (intent outside solver's chains/tokens)", - labelNames: ["solver"], - registers: [this.register], - }); - - // ── Restore-transaction metrics (issue #394) ─────────────────────────── - this.sorobanRestoreTotal = new client.Counter({ - name: `${prefix}soroban_restore_total`, - help: "Total RestoreFootprint transactions submitted", - labelNames: ["result"], - registers: [this.register], - }); - - this.sorobanRestoreFeeStroops = new client.Histogram({ - name: `${prefix}soroban_restore_fee_stroops`, - help: "Fee paid for RestoreFootprint transactions in stroops", - buckets: [1000, 5000, 10000, 50000, 100000, 500000, 1000000], - registers: [this.register], - }); - - // ── Remote signer latency (issue #400) ──────────────────────────────── - this.signerCallDurationSeconds = new client.Histogram({ - name: `${prefix}signer_call_duration_seconds`, - help: "Remote signer call latency in seconds", - labelNames: ["backend", "operation"], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - // ── Solver-registry event ingestion (issue #399) ────────────────────── - this.solverRegistryEventsTotal = new client.Counter({ - name: `${prefix}solver_registry_events_total`, - help: "Solver-registry contract events ingested by type", - labelNames: ["event_type"], - registers: [this.register], - }); - - // ── Anti-griefing controls (issue #453) ──────────────────────────────── - this.griefingStateTransitionsTotal = new client.Counter({ - name: `${prefix}griefing_state_transitions_total`, - help: "Anti-griefing enforcement state machine transitions per solver", - labelNames: ["solver", "from_state", "to_state"], - registers: [this.register], - }); - - this.griefingUnfilledRatio = new client.Gauge({ - name: `${prefix}griefing_unfilled_ratio`, - help: "Current rolling unfilled-accept ratio for solvers under enforcement", - labelNames: ["solver"], - registers: [this.register], - }); - - this.griefingEnforcedSolvers = new client.Gauge({ - name: `${prefix}griefing_enforced_solvers`, - help: "Number of solvers currently under anti-griefing enforcement (not in ok state)", - registers: [this.register], - }); - - this.griefingEnforcementState = new client.Gauge({ - name: `${prefix}griefing_enforcement_state`, - help: "1 when the solver is currently in the given enforcement state, 0 otherwise", - labelNames: ["solver", "state"], - registers: [this.register], - }); - - this.griefingConcurrencyLimit = new client.Gauge({ - name: `${prefix}griefing_concurrency_limit`, - help: "Effective concurrent-accept cap per solver while in reduced-concurrency (0 = unlimited)", - labelNames: ["solver"], - registers: [this.register], - }); - - // ── Shadow-mode divergence monitor (issue #401) ────────────────────────── - this.shadowComparisons = new client.Counter({ - name: `${prefix}shadow_comparisons_total`, - help: "Shadow-mode (expected, simulated) outcome pairs resolved, by transition, expected outcome and simulated outcome", - labelNames: ["transition", "expected", "outcome"], - registers: [this.register], - }); - - this.shadowDivergences = new client.Counter({ - name: `${prefix}shadow_divergences_total`, - help: "Shadow-mode divergences between the off-chain and simulated on-chain outcome, by transition and reason", - labelNames: ["transition", "reason"], - registers: [this.register], - }); - - this.shadowDropped = new client.Counter({ - name: `${prefix}shadow_dropped_total`, - help: "Shadow-mode observations dropped because the bounded queue was full", - registers: [this.register], - }); - - this.shadowQueueDepth = new client.Gauge({ - name: `${prefix}shadow_queue_depth`, - help: "Current number of queued shadow-mode observations awaiting simulation", - registers: [this.register], - }); - - // ── Leader election metrics (issue #493) ───────────────────────────────── - this.leaderElectionIsLeader = new client.Gauge({ - name: `${prefix}leader_election_is_leader`, - help: "1 when this replica is the current leader for the named worker, 0 otherwise", - labelNames: ["worker"], - registers: [this.register], - }); - - this.leaderElectionChangesTotal = new client.Counter({ - name: `${prefix}leader_election_changes_total`, - help: "Total number of leadership transitions (acquisitions + losses) per worker", - labelNames: ["worker", "transition"], - registers: [this.register], - }); - - // ── Background jobs (issue #494) ──────────────────────────────────────── - // Depth is sampled on scrape from the active queue driver, so it reflects - // every instance's shared view of the queue (BullMQ) without a timer. - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - this.jobsQueueDepth = new client.Gauge({ - name: `${prefix}jobs_queue_depth`, - help: "Jobs per queue and state (waiting, active, delayed, dead_letter)", - labelNames: ["queue", "state"], - registers: [this.register], - async collect() { - if (!self.queueDepthProvider) return; - this.reset(); - for (const { queue, state, count } of await self.queueDepthProvider()) { - this.set({ queue, state }, count); - } - }, - }); - - this.jobsDuration = new client.Histogram({ - name: `${prefix}jobs_duration_seconds`, - help: "Job handler latency in seconds", - labelNames: ["queue", "job", "outcome"], - buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 15, 60], - registers: [this.register], - }); - - this.jobsFailures = new client.Counter({ - name: `${prefix}jobs_failures_total`, - help: "Failed job attempts (including ones that will be retried)", - labelNames: ["queue", "job"], - registers: [this.register], - }); - - this.jobsDeadLettered = new client.Counter({ - name: `${prefix}jobs_dead_lettered_total`, - help: "Jobs moved to the dead-letter queue after exhausting retries", - labelNames: ["queue", "job"], - registers: [this.register], - }); - - // ── Feature flags (issue #495) ────────────────────────────────────────── - this.flagEvaluations = new client.Counter({ - name: `${prefix}flag_evaluations_total`, - help: "Feature-flag evaluations by flag, resolved value and reason", - labelNames: ["flag", "value", "reason"], - registers: [this.register], - }); - } - - /** Registers the source sampled for `vortex_jobs_queue_depth` on each scrape. */ - setQueueDepthProvider( - provider: () => Promise>, - ): void { - this.queueDepthProvider = provider; - } - - onModuleInit() { - const prefix = "vortex_"; - client.collectDefaultMetrics({ register: this.register, prefix }); - } - - async metrics(): Promise { - return this.register.metrics(); - } - - contentType(): string { - return this.register.contentType; - } - - incIntentStateTransition(from: string, to: string) { - this.intentStateTransitions.inc({ from_state: from, to_state: to }); - } - - incWsConnection() { - this.wsConnections.inc(); - } - - decWsConnection() { - this.wsConnections.dec(); - } - - /** - * Record one sweeper cycle's expired count and duration. - * Called by IntentsSweeperService at the end of every sweep() invocation. - */ - recordSweep(expiredCount: number, durationMs: number): void { - this.sweeperExpiredTotal.inc(expiredCount); - this.sweeperSweepDurationMs.observe(durationMs); - } - - /** - * Observe intent-create latency (SLO SLI, issue #480). - * Call from the create path with handler duration in seconds. - */ - observeIntentCreate(durationSeconds: number, route = "POST /api/v1/intents"): void { - this.intentCreateDuration.observe({ route }, durationSeconds); - } - - /** - * Observe WS end-to-end delivery latency (SLO SLI, issue #480). - * Call from the gateway broadcast path with queue-to-send duration. - */ - observeWsDelivery(durationSeconds: number): void { - this.wsDeliveryDuration.observe(durationSeconds); - } - - /** Set current event-ingestion lag in seconds (SLO SLI, issue #480). */ - setIngestionLag(lagSeconds: number): void { - this.eventIngestionLag.set(lagSeconds); - } - - /** Observe fill-to-confirmation latency in seconds (SLO SLI, issue #480). */ - observeTxConfirmation(durationSeconds: number): void { - this.txConfirmationDuration.observe(durationSeconds); - } - - // ── WS capability-filter helpers (issue #436) ──────────────────────────── - - /** Record a WS event delivered to an authenticated solver (post-filter). */ - incWsDelivered(solverAddress: string): void { - this.wsEventsDeliveredTotal.inc({ solver: solverAddress.slice(0, 12) }); - } - - /** Record a WS event suppressed for a solver by the capability filter. */ - incWsFiltered(solverAddress: string): void { - this.wsEventsFilteredTotal.inc({ solver: solverAddress.slice(0, 12) }); - } - - // ── Restore-transaction helpers (issue #394) ────────────────────────────── - - incSorobanRestore(result: "success" | "failed"): void { - this.sorobanRestoreTotal.inc({ result }); - } - - observeRestoreFee(stroops: number): void { - this.sorobanRestoreFeeStroops.observe(stroops); - } - - // ── Remote signer helpers (issue #400) ──────────────────────────────────── - - observeSignerCall(backend: string, operation: string, durationSeconds: number): void { - this.signerCallDurationSeconds.observe({ backend, operation }, durationSeconds); - } - - // ── Solver-registry event ingestion helpers (issue #399) ───────────────── - - incSolverRegistryEvent(eventType: string): void { - this.solverRegistryEventsTotal.inc({ event_type: eventType }); - } - - // ── Anti-griefing helpers (issue #453) ──────────────────────────────────── - - /** Record one anti-griefing enforcement state-machine transition. */ - recordGriefingTransition(solverAddress: string, fromState: string, toState: string): void { - this.griefingStateTransitionsTotal.inc({ - solver: solverAddress.slice(0, 12), - from_state: fromState, - to_state: toState, - }); - } - - /** Update the rolling unfilled-accept ratio for a solver under enforcement. */ - setGriefingRatio(solverAddress: string, ratio: number): void { - this.griefingUnfilledRatio.set({ solver: solverAddress.slice(0, 12) }, ratio); - } - - /** Set the count of solvers currently under enforcement. */ - setGriefingEnforcedCount(count: number): void { - this.griefingEnforcedSolvers.set(count); - } - - /** - * Update per-solver enforcement state gauges. - * - * Sets the named state label to 1 and all other enforcement states to 0 - * so dashboards can query `{state="suspended"}` without stale series. - */ - setGriefingEnforcementState(solverAddress: string, state: string): void { - const s = solverAddress.slice(0, 12); - for (const st of ["ok", "cooldown", "reduced-concurrency", "suspended"]) { - this.griefingEnforcementState.set({ solver: s, state: st }, st === state ? 1 : 0); - } - } - - /** - * Update the effective concurrency cap for a solver. - * Pass 0 when no limit is active (state is not "reduced-concurrency"). - */ - setGriefingConcurrencyLimit(solverAddress: string, limit: number): void { - this.griefingConcurrencyLimit.set({ solver: solverAddress.slice(0, 12) }, limit); - } - - /** - * Record one resolved shadow-mode comparison (issue #401). - * - * `expected` is the off-chain verdict and `outcome` the simulated one, so - * the pair required by the issue stays queryable from PromQL: - * `...{expected="ok",outcome="rejected"}` is the "contract would have - * refused a transition we committed" case, and the reverse label pair is the - * "we refused something the contract allows" case. Cardinality is bounded at - * 5 transitions x 2 expected x 4 outcomes. - * - * `outcome` is `"unavailable"` when the simulation never produced a verdict - * (unconfigured contract, RPC unreachable) so that case stays - * distinguishable in PromQL from a contract that actively said no. - */ - recordShadowComparison(transition: string, expected: string, outcome: string): void { - this.shadowComparisons.inc({ transition, expected, outcome }); - } - - /** Record one classified shadow-mode divergence (issue #401). */ - recordShadowDivergence(transition: string, reason: string): void { - this.shadowDivergences.inc({ transition, reason }); - } - - /** Record one shadow-mode observation dropped by the bounded queue. */ - recordShadowDrop(): void { - this.shadowDropped.inc(); - } - - /** Publish the current shadow queue depth. */ - setShadowQueueDepth(depth: number): void { - this.shadowQueueDepth.set(depth); - } - - /** - * Record that this replica acquired leadership for `workerName`. - * Sets the is_leader gauge to 1 and increments the acquisition counter. - */ - recordLeadershipAcquired(workerName: string): void { - this.leaderElectionIsLeader.set({ worker: workerName }, 1); - this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "acquired" }); - } - - /** - * Record that this replica lost leadership for `workerName`. - * Sets the is_leader gauge to 0 and increments the lost counter. - */ - recordLeadershipLost(workerName: string): void { - this.leaderElectionIsLeader.set({ worker: workerName }, 0); - this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "lost" }); - } -} -import { Injectable, OnModuleInit } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import client from "prom-client"; -import { AppConfig } from "../config/configuration"; - -@Injectable() -export class MetricsService implements OnModuleInit { - private readonly register: client.Registry; - - // ── HTTP ─────────────────────────────────────────────────────────────────── - public readonly httpRequestDuration: client.Histogram; - public readonly httpRequestTotal: client.Counter; - public readonly httpRequestErrors: client.Counter; - - // ── Intent / WS general ─────────────────────────────────────────────────── - public readonly intentStateTransitions: client.Counter; - public readonly wsConnections: client.Gauge; - public readonly intentCreateDuration: client.Histogram; - public readonly wsDeliveryDuration: client.Histogram; - public readonly eventIngestionLag: client.Gauge; - - /** - * Shadow-mode divergence monitor (issue #401). - * - * `vortex_shadow_comparisons_total{transition,outcome}` counts every - * (expected, simulated) pair the monitor resolved, and - * `vortex_shadow_divergences_total{transition,reason}` counts the subset the - * classifier flagged. `vortex_shadow_dropped_total` and - * `vortex_shadow_queue_depth` expose monitor health so a starved monitor is - * never mistaken for a healthy one — the on-chain cutover runbook's go/no-go - * threshold is only meaningful while these are being exercised. - */ - public readonly shadowComparisons: client.Counter; - public readonly shadowDivergences: client.Counter; - public readonly shadowDropped: client.Counter; - public readonly shadowQueueDepth: client.Gauge; - - /** - * Leader election metrics (issue #493). - * Track which replica is leader per worker and how often leadership changes. - */ - public readonly leaderElectionIsLeader: client.Gauge; - public readonly leaderElectionChangesTotal: client.Counter; - - /** Background job metrics (issue #494). */ - public readonly jobsQueueDepth: client.Gauge; - public readonly jobsDuration: client.Histogram; - public readonly jobsFailures: client.Counter; - public readonly jobsDeadLettered: client.Counter; - private queueDepthProvider?: () => Promise>; - - /** Feature-flag evaluations (issue #495). */ - public readonly flagEvaluations: client.Counter; - - /** - * Sweeper metrics — these replace the retired src/common/metrics.ts - * MetricsRegistry.sweeper namespace (see issue #259). - * - * The on-call runbook (docs/runbooks/on-call.md) references these names - * directly. Any change here must be reflected there. - */ - public readonly sweeperExpiredTotal: client.Counter; - public readonly sweeperSweepDurationMs: client.Histogram; - - // ── SLO SLIs (issue #480) ───────────────────────────────────────────────── - public readonly txConfirmationDuration: client.Histogram; - - // ── WS capability-filter metrics (issue #436) ──────────────────────────── - /** - * WS events delivered to an authenticated solver after capability filtering. - * Label `solver` is truncated to 12 chars to bound Prometheus label cardinality. - */ - public readonly wsEventsDeliveredTotal: client.Counter; - /** - * WS events suppressed by the capability filter (intent outside solver's - * supported chains/tokens or solver bond = 0). - */ - public readonly wsEventsFilteredTotal: client.Counter; - - // ── Restore-transaction metrics (issue #394) ───────────────────────────── - public readonly sorobanRestoreTotal: client.Counter; - public readonly sorobanRestoreFeeStroops: client.Histogram; - - // ── Remote signer call latency (issue #400) ─────────────────────────────── - public readonly signerCallDurationSeconds: client.Histogram; - - // ── Solver-registry event ingestion (issue #399) ────────────────────────── - public readonly solverRegistryEventsTotal: client.Counter; - - constructor(private readonly configService: ConfigService) { - this.register = new client.Registry(); - const prefix = "vortex_"; - - this.httpRequestDuration = new client.Histogram({ - name: `${prefix}http_request_duration_seconds`, - help: "HTTP request duration in seconds", - labelNames: ["method", "route", "status_code", "version"], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], - registers: [this.register], - }); - - this.httpRequestTotal = new client.Counter({ - name: `${prefix}http_requests_total`, - help: "Total number of HTTP requests", - labelNames: ["method", "route", "status_code", "version"], - registers: [this.register], - }); - - this.httpRequestErrors = new client.Counter({ - name: `${prefix}http_request_errors_total`, - help: "Total number of HTTP request errors (5xx)", - labelNames: ["method", "route", "status_code", "version"], - registers: [this.register], - }); - - this.intentStateTransitions = new client.Counter({ - name: `${prefix}intent_state_transitions_total`, - help: "Total number of intent state transitions", - labelNames: ["from_state", "to_state"], - registers: [this.register], - }); - - this.wsConnections = new client.Gauge({ - name: `${prefix}ws_connections_active`, - help: "Number of active WebSocket connections", - registers: [this.register], - }); - - // ── Sweeper metrics (issue #259) ───────────────────────────────────────── - this.sweeperExpiredTotal = new client.Counter({ - name: `${prefix}sweeper_expired_total`, - help: "Total number of intents expired across all sweeps", - registers: [this.register], - }); - - this.sweeperSweepDurationMs = new client.Histogram({ - name: `${prefix}sweeper_sweep_duration_ms`, - help: "Duration of each IntentsSweeperService.sweep() execution in milliseconds", - buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000], - registers: [this.register], - }); - - // ── SLO SLIs (issue #480) ─────────────────────────────────────────────── - this.intentCreateDuration = new client.Histogram({ - name: `${prefix}intent_create_duration_seconds`, - help: "Intent-create handler latency in seconds", - labelNames: ["route"], - buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - this.wsDeliveryDuration = new client.Histogram({ - name: `${prefix}ws_delivery_duration_seconds`, - help: "WS end-to-end delivery latency (broadcast to send) in seconds", - buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - this.eventIngestionLag = new client.Gauge({ - name: `${prefix}event_ingestion_lag_seconds`, - help: "Event-ingestion lag: now minus newest ingested event timestamp", - registers: [this.register], - }); - - this.txConfirmationDuration = new client.Histogram({ - name: `${prefix}tx_confirmation_duration_seconds`, - help: "Fill submission to on-chain confirmation latency in seconds", - buckets: [1, 5, 15, 30, 60, 120, 300], - registers: [this.register], - }); - - // ── WS capability-filter metrics (issue #436) ────────────────────────── - this.wsEventsDeliveredTotal = new client.Counter({ - name: `${prefix}ws_events_delivered_total`, - help: "WS events delivered to authenticated solvers after capability filtering", - labelNames: ["solver"], - registers: [this.register], - }); - - this.wsEventsFilteredTotal = new client.Counter({ - name: `${prefix}ws_events_filtered_total`, - help: "WS events suppressed by capability filter (intent outside solver's chains/tokens)", - labelNames: ["solver"], - registers: [this.register], - }); - - // ── Restore-transaction metrics (issue #394) ─────────────────────────── - this.sorobanRestoreTotal = new client.Counter({ - name: `${prefix}soroban_restore_total`, - help: "Total RestoreFootprint transactions submitted", - labelNames: ["result"], - registers: [this.register], - }); - - this.sorobanRestoreFeeStroops = new client.Histogram({ - name: `${prefix}soroban_restore_fee_stroops`, - help: "Fee paid for RestoreFootprint transactions in stroops", - buckets: [1000, 5000, 10000, 50000, 100000, 500000, 1000000], - registers: [this.register], - }); - - // ── Remote signer latency (issue #400) ──────────────────────────────── - this.signerCallDurationSeconds = new client.Histogram({ - name: `${prefix}signer_call_duration_seconds`, - help: "Remote signer call latency in seconds", - labelNames: ["backend", "operation"], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], - registers: [this.register], - }); - - // ── Solver-registry event ingestion (issue #399) ────────────────────── - this.solverRegistryEventsTotal = new client.Counter({ - name: `${prefix}solver_registry_events_total`, - help: "Solver-registry contract events ingested by type", - labelNames: ["event_type"], - registers: [this.register], - }); - - // ── Shadow-mode divergence monitor (issue #401) ────────────────────────── - this.shadowComparisons = new client.Counter({ - name: `${prefix}shadow_comparisons_total`, - help: "Shadow-mode (expected, simulated) outcome pairs resolved, by transition, expected outcome and simulated outcome", - labelNames: ["transition", "expected", "outcome"], - registers: [this.register], - }); - - this.shadowDivergences = new client.Counter({ - name: `${prefix}shadow_divergences_total`, - help: "Shadow-mode divergences between the off-chain and simulated on-chain outcome, by transition and reason", - labelNames: ["transition", "reason"], - registers: [this.register], - }); - - this.shadowDropped = new client.Counter({ - name: `${prefix}shadow_dropped_total`, - help: "Shadow-mode observations dropped because the bounded queue was full", - registers: [this.register], - }); - - this.shadowQueueDepth = new client.Gauge({ - name: `${prefix}shadow_queue_depth`, - help: "Current number of queued shadow-mode observations awaiting simulation", - registers: [this.register], - }); - - // ── Leader election metrics (issue #493) ───────────────────────────────── - this.leaderElectionIsLeader = new client.Gauge({ - name: `${prefix}leader_election_is_leader`, - help: "1 when this replica is the current leader for the named worker, 0 otherwise", - labelNames: ["worker"], - registers: [this.register], - }); - - this.leaderElectionChangesTotal = new client.Counter({ - name: `${prefix}leader_election_changes_total`, - help: "Total number of leadership transitions (acquisitions + losses) per worker", - labelNames: ["worker", "transition"], - registers: [this.register], - }); - - // ── Background jobs (issue #494) ──────────────────────────────────────── - // Depth is sampled on scrape from the active queue driver, so it reflects - // every instance's shared view of the queue (BullMQ) without a timer. - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - this.jobsQueueDepth = new client.Gauge({ - name: `${prefix}jobs_queue_depth`, - help: "Jobs per queue and state (waiting, active, delayed, dead_letter)", - labelNames: ["queue", "state"], - registers: [this.register], - async collect() { - if (!self.queueDepthProvider) return; - this.reset(); - for (const { queue, state, count } of await self.queueDepthProvider()) { - this.set({ queue, state }, count); - } - }, - }); - - this.jobsDuration = new client.Histogram({ - name: `${prefix}jobs_duration_seconds`, - help: "Job handler latency in seconds", - labelNames: ["queue", "job", "outcome"], - buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 15, 60], - registers: [this.register], - }); - - this.jobsFailures = new client.Counter({ - name: `${prefix}jobs_failures_total`, - help: "Failed job attempts (including ones that will be retried)", - labelNames: ["queue", "job"], - registers: [this.register], - }); - - this.jobsDeadLettered = new client.Counter({ - name: `${prefix}jobs_dead_lettered_total`, - help: "Jobs moved to the dead-letter queue after exhausting retries", - labelNames: ["queue", "job"], - registers: [this.register], - }); - - // ── Feature flags (issue #495) ────────────────────────────────────────── - this.flagEvaluations = new client.Counter({ - name: `${prefix}flag_evaluations_total`, - help: "Feature-flag evaluations by flag, resolved value and reason", - labelNames: ["flag", "value", "reason"], - registers: [this.register], - }); - } - - /** Registers the source sampled for `vortex_jobs_queue_depth` on each scrape. */ - setQueueDepthProvider( - provider: () => Promise>, - ): void { - this.queueDepthProvider = provider; - } - - onModuleInit() { - const prefix = "vortex_"; - client.collectDefaultMetrics({ register: this.register, prefix }); - } - - async metrics(): Promise { - return this.register.metrics(); - } - - contentType(): string { - return this.register.contentType; - } - - incIntentStateTransition(from: string, to: string) { - this.intentStateTransitions.inc({ from_state: from, to_state: to }); - } - - incWsConnection() { - this.wsConnections.inc(); - } - - decWsConnection() { - this.wsConnections.dec(); - } - - /** - * Record one sweeper cycle's expired count and duration. - * Called by IntentsSweeperService at the end of every sweep() invocation. - */ - recordSweep(expiredCount: number, durationMs: number): void { - this.sweeperExpiredTotal.inc(expiredCount); - this.sweeperSweepDurationMs.observe(durationMs); - } - - /** - * Observe intent-create latency (SLO SLI, issue #480). - * Call from the create path with handler duration in seconds. - */ - observeIntentCreate(durationSeconds: number, route = "POST /api/v1/intents"): void { - this.intentCreateDuration.observe({ route }, durationSeconds); - } - - /** - * Observe WS end-to-end delivery latency (SLO SLI, issue #480). - * Call from the gateway broadcast path with queue-to-send duration. - */ - observeWsDelivery(durationSeconds: number): void { - this.wsDeliveryDuration.observe(durationSeconds); - } - - /** Set current event-ingestion lag in seconds (SLO SLI, issue #480). */ - setIngestionLag(lagSeconds: number): void { - this.eventIngestionLag.set(lagSeconds); - } - - /** Observe fill-to-confirmation latency in seconds (SLO SLI, issue #480). */ - observeTxConfirmation(durationSeconds: number): void { - this.txConfirmationDuration.observe(durationSeconds); - } - - // ── WS capability-filter helpers (issue #436) ──────────────────────────── - - /** Record a WS event delivered to an authenticated solver (post-filter). */ - incWsDelivered(solverAddress: string): void { - this.wsEventsDeliveredTotal.inc({ solver: solverAddress.slice(0, 12) }); - } - - /** Record a WS event suppressed for a solver by the capability filter. */ - incWsFiltered(solverAddress: string): void { - this.wsEventsFilteredTotal.inc({ solver: solverAddress.slice(0, 12) }); - } - - // ── Restore-transaction helpers (issue #394) ────────────────────────────── - - incSorobanRestore(result: "success" | "failed"): void { - this.sorobanRestoreTotal.inc({ result }); - } - - observeRestoreFee(stroops: number): void { - this.sorobanRestoreFeeStroops.observe(stroops); - } - - // ── Remote signer helpers (issue #400) ──────────────────────────────────── - - observeSignerCall(backend: string, operation: string, durationSeconds: number): void { - this.signerCallDurationSeconds.observe({ backend, operation }, durationSeconds); - } - - // ── Solver-registry event ingestion helpers (issue #399) ───────────────── - - incSolverRegistryEvent(eventType: string): void { - this.solverRegistryEventsTotal.inc({ event_type: eventType }); - } - - /** - * Record one resolved shadow-mode comparison (issue #401). - * - * `expected` is the off-chain verdict and `outcome` the simulated one, so - * the pair required by the issue stays queryable from PromQL: - * `...{expected="ok",outcome="rejected"}` is the "contract would have - * refused a transition we committed" case, and the reverse label pair is the - * "we refused something the contract allows" case. Cardinality is bounded at - * 5 transitions x 2 expected x 4 outcomes. - * - * `outcome` is `"unavailable"` when the simulation never produced a verdict - * (unconfigured contract, RPC unreachable) so that case stays - * distinguishable in PromQL from a contract that actively said no. - */ - recordShadowComparison(transition: string, expected: string, outcome: string): void { - this.shadowComparisons.inc({ transition, expected, outcome }); - } - - /** Record one classified shadow-mode divergence (issue #401). */ - recordShadowDivergence(transition: string, reason: string): void { - this.shadowDivergences.inc({ transition, reason }); - } - - /** Record one shadow-mode observation dropped by the bounded queue. */ - recordShadowDrop(): void { - this.shadowDropped.inc(); - } - - /** Publish the current shadow queue depth. */ - setShadowQueueDepth(depth: number): void { - this.shadowQueueDepth.set(depth); - } - - /** - * Record that this replica acquired leadership for `workerName`. - * Sets the is_leader gauge to 1 and increments the acquisition counter. - */ - recordLeadershipAcquired(workerName: string): void { - this.leaderElectionIsLeader.set({ worker: workerName }, 1); - this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "acquired" }); - } - - /** - * Record that this replica lost leadership for `workerName`. - * Sets the is_leader gauge to 0 and increments the lost counter. - */ - recordLeadershipLost(workerName: string): void { - this.leaderElectionIsLeader.set({ worker: workerName }, 0); - this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "lost" }); - } -} +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import client from "prom-client"; +import { AppConfig } from "../config/configuration"; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // ── HTTP ─────────────────────────────────────────────────────────────────── + public readonly httpRequestDuration: client.Histogram; + public readonly httpRequestTotal: client.Counter; + public readonly httpRequestErrors: client.Counter; + + // ── Intent / WS general ─────────────────────────────────────────────────── + public readonly intentStateTransitions: client.Counter; + public readonly wsConnections: client.Gauge; + public readonly intentCreateDuration: client.Histogram; + public readonly wsDeliveryDuration: client.Histogram; + public readonly eventIngestionLag: client.Gauge; + + /** + * Shadow-mode divergence monitor (issue #401). + * + * `vortex_shadow_comparisons_total{transition,outcome}` counts every + * (expected, simulated) pair the monitor resolved, and + * `vortex_shadow_divergences_total{transition,reason}` counts the subset the + * classifier flagged. `vortex_shadow_dropped_total` and + * `vortex_shadow_queue_depth` expose monitor health so a starved monitor is + * never mistaken for a healthy one — the on-chain cutover runbook's go/no-go + * threshold is only meaningful while these are being exercised. + */ + public readonly shadowComparisons: client.Counter; + public readonly shadowDivergences: client.Counter; + public readonly shadowDropped: client.Counter; + public readonly shadowQueueDepth: client.Gauge; + + /** + * Leader election metrics (issue #493). + * Track which replica is leader per worker and how often leadership changes. + */ + public readonly leaderElectionIsLeader: client.Gauge; + public readonly leaderElectionChangesTotal: client.Counter; + + /** Background job metrics (issue #494). */ + public readonly jobsQueueDepth: client.Gauge; + public readonly jobsDuration: client.Histogram; + public readonly jobsFailures: client.Counter; + public readonly jobsDeadLettered: client.Counter; + private queueDepthProvider?: () => Promise>; + + /** Feature-flag evaluations (issue #495). */ + public readonly flagEvaluations: client.Counter; + // ── WS backplane (issue #454) ───────────────────────────────────────────── + public readonly wsBackplanePublishDuration: client.Histogram; + public readonly wsBackplaneDropped: client.Counter; + public readonly wsBackplaneConnected: client.Gauge; + + // ── WS hardening (issue #455) ───────────────────────────────────────────── + public readonly wsConnectionsRejected: client.Counter; + public readonly wsRateLimited: client.Counter; + public readonly wsOutboundDropped: client.Counter; + public readonly wsSlowConsumerDisconnects: client.Counter; + + // ── Health (issue #492) ─────────────────────────────────────────────────── + public readonly healthIndicatorUp: client.Gauge; + public readonly healthReady: client.Gauge; + public readonly healthCheckDuration: client.Histogram; + + /** + * Sweeper metrics — these replace the retired src/common/metrics.ts + * MetricsRegistry.sweeper namespace (see issue #259). + * + * The on-call runbook (docs/runbooks/on-call.md) references these names + * directly. Any change here must be reflected there. + */ + public readonly sweeperExpiredTotal: client.Counter; + public readonly sweeperSweepDurationMs: client.Histogram; + /** Intents the low-frequency safety sweep expired or slashed. Steady state is ~0. */ + public readonly sweeperSafetyCaughtTotal: client.Counter; + + // ── SLO SLIs (issue #480) ───────────────────────────────────────────────── + public readonly txConfirmationDuration: client.Histogram; + + // ── WS capability-filter metrics (issue #436) ──────────────────────────── + /** + * WS events delivered to an authenticated solver after capability filtering. + * Label `solver` is truncated to 12 chars to bound Prometheus label cardinality. + */ + public readonly wsEventsDeliveredTotal: client.Counter; + /** + * WS events suppressed by the capability filter (intent outside solver's + * supported chains/tokens or solver bond = 0). + */ + public readonly wsEventsFilteredTotal: client.Counter; + + // ── Restore-transaction metrics (issue #394) ───────────────────────────── + public readonly sorobanRestoreTotal: client.Counter; + public readonly sorobanRestoreFeeStroops: client.Histogram; + + // ── Remote signer call latency (issue #400) ─────────────────────────────── + public readonly signerCallDurationSeconds: client.Histogram; + + // ── Solver-registry event ingestion (issue #399) ────────────────────────── + public readonly solverRegistryEventsTotal: client.Counter; + + // ── Anti-griefing controls (issue #453) ────────────────────────────────── + /** + * `vortex_griefing_state_transitions_total{solver,from_state,to_state}` — counts + * every enforcement escalation/recovery for a solver. Solver label is + * truncated to 12 chars to bound cardinality. + */ + public readonly griefingStateTransitionsTotal: client.Counter; + /** + * `vortex_griefing_unfilled_ratio{solver}` — current rolling unfilled/accept + * ratio per solver under enforcement (Gauge, updated on each unfilled event). + */ + public readonly griefingUnfilledRatio: client.Gauge; + /** + * `vortex_griefing_enforced_solvers` — number of solvers currently NOT in + * the "ok" state. + */ + public readonly griefingEnforcedSolvers: client.Gauge; + /** + * `vortex_griefing_enforcement_state{solver,state}` — current enforcement + * state as a 0/1 gauge per (solver, state) label pair. Allows dashboards and + * alerts to query "how many solvers are suspended right now" with a simple + * `sum(vortex_griefing_enforcement_state{state="suspended"})`. + */ + public readonly griefingEnforcementState: client.Gauge; + /** + * `vortex_griefing_concurrency_limit{solver}` — effective concurrent-accept + * cap while in "reduced-concurrency" state; 0 when no limit is active. + */ + public readonly griefingConcurrencyLimit: client.Gauge; + public readonly legacyStellarSignatures: client.Counter; + + /** Dual-write / consistency-verifier metrics (issue #404). */ + public readonly intentsDualWriteFailuresTotal: client.Counter; + public readonly intentsStoreMismatches: client.Gauge; + public readonly intentsStoreMismatchesTotal: client.Counter; + public readonly intentsStoreVerifierRunsTotal: client.Counter; + + /** Contract version gating metrics (issue #402). */ + public readonly contractVersionSupported: client.Gauge; + public readonly contractUpgradesTotal: client.Counter; + public readonly contractWritesBlockedTotal: client.Counter; + + /** Source-chain deposit verification (issue #403). */ + public readonly srcVerificationsTotal: client.Counter; + public readonly srcVerificationErrorsTotal: client.Counter; + public readonly srcVerificationQueueSize: client.Gauge; + + /** Transactional outbox relay (issue #396). */ + public readonly outboxRelayOutcomes: client.Counter; + public readonly outboxDeadTotal: client.Counter; + public readonly outboxBacklog: client.Gauge; + + /** Slashing saga (issue #397). */ + public readonly slashTransitions: client.Counter; + + // ── Channel pool leasing (issue #473) ───────────────────────────────────── + /** Seconds spent waiting for a channel lease before the attempt resolved. */ + public readonly channelLeaseWaitTime: client.Histogram; + /** `Seq`-mismatch reconnects forced on a channel by a bad sequence number. */ + public readonly channelBadSeqResyncs: client.Counter; + /** Fraction (0..1) of the channel pool currently leased. */ + public readonly channelPoolUtilisation: client.Gauge; + + // ── Transaction confirmation outcomes (issue #385) ──────────────────────── + /** Tracked transaction outcomes, by status (confirmed | expired | …). */ + public readonly txConfirmationOutcomes: client.Counter; + + // ── Fee-bump escalations (issue #454) ───────────────────────────────────── + /** Fee-bump transactions built, by inclusion-fee percentile. */ + public readonly txFeeBumpTotal: client.Counter; + /** Refusals to escalate past `maxFeeStroops` (page on any increase). */ + public readonly txFeeBumpCeilingHits: client.Counter; + + constructor(private readonly configService: ConfigService) { + this.register = new client.Registry(); + const prefix = "vortex_"; + + this.httpRequestDuration = new client.Histogram({ + name: `${prefix}http_request_duration_seconds`, + help: "HTTP request duration in seconds", + labelNames: ["method", "route", "status_code", "version"], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [this.register], + }); + + this.httpRequestTotal = new client.Counter({ + name: `${prefix}http_requests_total`, + help: "Total number of HTTP requests", + labelNames: ["method", "route", "status_code", "version"], + registers: [this.register], + }); + + this.httpRequestErrors = new client.Counter({ + name: `${prefix}http_request_errors_total`, + help: "Total number of HTTP request errors (5xx)", + labelNames: ["method", "route", "status_code", "version"], + registers: [this.register], + }); + + this.intentStateTransitions = new client.Counter({ + name: `${prefix}intent_state_transitions_total`, + help: "Total number of intent state transitions", + labelNames: ["from_state", "to_state"], + registers: [this.register], + }); + + this.wsConnections = new client.Gauge({ + name: `${prefix}ws_connections_active`, + help: "Number of active WebSocket connections", + registers: [this.register], + }); + + // ── Sweeper metrics (issue #259) ───────────────────────────────────────── + this.sweeperExpiredTotal = new client.Counter({ + name: `${prefix}sweeper_expired_total`, + help: "Total number of intents expired across all sweeps", + registers: [this.register], + }); + + this.sweeperSweepDurationMs = new client.Histogram({ + name: `${prefix}sweeper_sweep_duration_ms`, + help: "Duration of each IntentsSweeperService.sweep() execution in milliseconds", + buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000], + registers: [this.register], + }); + + this.sweeperSafetyCaughtTotal = new client.Counter({ + name: `${prefix}sweeper_safety_caught_total`, + help: "Intents expired or slashed by the low-frequency safety sweep (lost deadline jobs). Should stay near zero.", + registers: [this.register], + }); + + // ── SLO SLIs (issue #480) ─────────────────────────────────────────────── + this.intentCreateDuration = new client.Histogram({ + name: `${prefix}intent_create_duration_seconds`, + help: "Intent-create handler latency in seconds", + labelNames: ["route"], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [this.register], + }); + + this.wsDeliveryDuration = new client.Histogram({ + name: `${prefix}ws_delivery_duration_seconds`, + help: "WS end-to-end delivery latency (broadcast to send) in seconds", + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [this.register], + }); + + this.eventIngestionLag = new client.Gauge({ + name: `${prefix}event_ingestion_lag_seconds`, + help: "Event-ingestion lag: now minus newest ingested event timestamp", + registers: [this.register], + }); + + this.txConfirmationDuration = new client.Histogram({ + name: `${prefix}tx_confirmation_duration_seconds`, + help: "Fill submission to on-chain confirmation latency in seconds", + buckets: [1, 5, 15, 30, 60, 120, 300], + registers: [this.register], + }); + + // ── WS capability-filter metrics (issue #436) ────────────────────────── + this.wsEventsDeliveredTotal = new client.Counter({ + name: `${prefix}ws_events_delivered_total`, + help: "WS events delivered to authenticated solvers after capability filtering", + labelNames: ["solver"], + registers: [this.register], + }); + + this.wsEventsFilteredTotal = new client.Counter({ + name: `${prefix}ws_events_filtered_total`, + help: "WS events suppressed by capability filter (intent outside solver's chains/tokens)", + labelNames: ["solver"], + registers: [this.register], + }); + + // ── Restore-transaction metrics (issue #394) ─────────────────────────── + this.sorobanRestoreTotal = new client.Counter({ + name: `${prefix}soroban_restore_total`, + help: "Total RestoreFootprint transactions submitted", + labelNames: ["result"], + registers: [this.register], + }); + + this.sorobanRestoreFeeStroops = new client.Histogram({ + name: `${prefix}soroban_restore_fee_stroops`, + help: "Fee paid for RestoreFootprint transactions in stroops", + buckets: [1000, 5000, 10000, 50000, 100000, 500000, 1000000], + registers: [this.register], + }); + + // ── Remote signer latency (issue #400) ──────────────────────────────── + this.signerCallDurationSeconds = new client.Histogram({ + name: `${prefix}signer_call_duration_seconds`, + help: "Remote signer call latency in seconds", + labelNames: ["backend", "operation"], + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + registers: [this.register], + }); + + // ── Solver-registry event ingestion (issue #399) ────────────────────── + this.solverRegistryEventsTotal = new client.Counter({ + name: `${prefix}solver_registry_events_total`, + help: "Solver-registry contract events ingested by type", + labelNames: ["event_type"], + registers: [this.register], + }); + + this.legacyStellarSignatures = new client.Counter({ + name: `${prefix}legacy_stellar_signatures_total`, + help: "Accepted version 1 Stellar intent signatures during the deprecation window", + labelNames: ["action"], + registers: [this.register], + }); + + // ── Anti-griefing controls (issue #453) ──────────────────────────────── + this.griefingStateTransitionsTotal = new client.Counter({ + name: `${prefix}griefing_state_transitions_total`, + help: "Anti-griefing enforcement state machine transitions per solver", + labelNames: ["solver", "from_state", "to_state"], + registers: [this.register], + }); + + this.griefingUnfilledRatio = new client.Gauge({ + name: `${prefix}griefing_unfilled_ratio`, + help: "Current rolling unfilled-accept ratio for solvers under enforcement", + labelNames: ["solver"], + registers: [this.register], + }); + + this.griefingEnforcedSolvers = new client.Gauge({ + name: `${prefix}griefing_enforced_solvers`, + help: "Number of solvers currently under anti-griefing enforcement (not in ok state)", + registers: [this.register], + }); + + this.griefingEnforcementState = new client.Gauge({ + name: `${prefix}griefing_enforcement_state`, + help: "1 when the solver is currently in the given enforcement state, 0 otherwise", + labelNames: ["solver", "state"], + registers: [this.register], + }); + + this.griefingConcurrencyLimit = new client.Gauge({ + name: `${prefix}griefing_concurrency_limit`, + help: "Effective concurrent-accept cap per solver while in reduced-concurrency (0 = unlimited)", + labelNames: ["solver"], + registers: [this.register], + }); + + // ── Shadow-mode divergence monitor (issue #401) ────────────────────────── + this.shadowComparisons = new client.Counter({ + name: `${prefix}shadow_comparisons_total`, + help: "Shadow-mode (expected, simulated) outcome pairs resolved, by transition, expected outcome and simulated outcome", + labelNames: ["transition", "expected", "outcome"], + registers: [this.register], + }); + + this.shadowDivergences = new client.Counter({ + name: `${prefix}shadow_divergences_total`, + help: "Shadow-mode divergences between the off-chain and simulated on-chain outcome, by transition and reason", + labelNames: ["transition", "reason"], + registers: [this.register], + }); + + this.shadowDropped = new client.Counter({ + name: `${prefix}shadow_dropped_total`, + help: "Shadow-mode observations dropped because the bounded queue was full", + registers: [this.register], + }); + + this.shadowQueueDepth = new client.Gauge({ + name: `${prefix}shadow_queue_depth`, + help: "Current number of queued shadow-mode observations awaiting simulation", + registers: [this.register], + }); + + // ── Leader election metrics (issue #493) ───────────────────────────────── + this.leaderElectionIsLeader = new client.Gauge({ + name: `${prefix}leader_election_is_leader`, + help: "1 when this replica is the current leader for the named worker, 0 otherwise", + labelNames: ["worker"], + registers: [this.register], + }); + + this.leaderElectionChangesTotal = new client.Counter({ + name: `${prefix}leader_election_changes_total`, + help: "Total number of leadership transitions (acquisitions + losses) per worker", + labelNames: ["worker", "transition"], + registers: [this.register], + }); + + // ── Background jobs (issue #494) ──────────────────────────────────────── + // Depth is sampled on scrape from the active queue driver, so it reflects + // every instance's shared view of the queue (BullMQ) without a timer. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + this.jobsQueueDepth = new client.Gauge({ + name: `${prefix}jobs_queue_depth`, + help: "Jobs per queue and state (waiting, active, delayed, dead_letter)", + labelNames: ["queue", "state"], + registers: [this.register], + async collect() { + if (!self.queueDepthProvider) return; + this.reset(); + for (const { queue, state, count } of await self.queueDepthProvider()) { + this.set({ queue, state }, count); + } + }, + }); + + this.jobsDuration = new client.Histogram({ + name: `${prefix}jobs_duration_seconds`, + help: "Job handler latency in seconds", + labelNames: ["queue", "job", "outcome"], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 5, 15, 60], + registers: [this.register], + }); + + this.jobsFailures = new client.Counter({ + name: `${prefix}jobs_failures_total`, + help: "Failed job attempts (including ones that will be retried)", + labelNames: ["queue", "job"], + registers: [this.register], + }); + + this.jobsDeadLettered = new client.Counter({ + name: `${prefix}jobs_dead_lettered_total`, + help: "Jobs moved to the dead-letter queue after exhausting retries", + labelNames: ["queue", "job"], + registers: [this.register], + }); + + // ── Feature flags (issue #495) ────────────────────────────────────────── + this.flagEvaluations = new client.Counter({ + name: `${prefix}flag_evaluations_total`, + help: "Feature-flag evaluations by flag, resolved value and reason", + labelNames: ["flag", "value", "reason"], + registers: [this.register], + }); + + // ── WS backplane (issue #454) ──────────────────────────────────────────── + this.wsBackplanePublishDuration = new client.Histogram({ + name: `${prefix}ws_backplane_publish_duration_seconds`, + help: "Time to sequence one WS event through the Redis backplane", + buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 1], + registers: [this.register], + }); + this.wsBackplaneDropped = new client.Counter({ + name: `${prefix}ws_backplane_dropped_total`, + help: "WS events dropped by the backplane, by reason", + labelNames: ["reason"], + registers: [this.register], + }); + this.wsBackplaneConnected = new client.Gauge({ + name: `${prefix}ws_backplane_connected`, + help: "1 while this replica is reading from the Redis backplane", + registers: [this.register], + }); + + // ── WS hardening (issue #455) ──────────────────────────────────────────── + this.wsConnectionsRejected = new client.Counter({ + name: `${prefix}ws_connections_rejected_total`, + help: "WS connections refused at admission, by reason (max_connections, per_ip)", + labelNames: ["reason"], + registers: [this.register], + }); + this.wsRateLimited = new client.Counter({ + name: `${prefix}ws_rate_limited_total`, + help: "Inbound WS messages rejected by the per-connection token bucket, by action", + labelNames: ["action"], + registers: [this.register], + }); + this.wsOutboundDropped = new client.Counter({ + name: `${prefix}ws_outbound_dropped_total`, + help: "Outbound WS messages dropped for slow consumers (drop-oldest policy)", + registers: [this.register], + }); + this.wsSlowConsumerDisconnects = new client.Counter({ + name: `${prefix}ws_slow_consumer_disconnects_total`, + help: "WS connections closed because their outbound queue exceeded its bound", + registers: [this.register], + }); + + // ── Health (issue #492) ────────────────────────────────────────────────── + this.healthIndicatorUp = new client.Gauge({ + name: `${prefix}health_indicator_up`, + help: "1 when the named health indicator's last check passed, else 0", + labelNames: ["indicator"], + registers: [this.register], + }); + this.healthReady = new client.Gauge({ + name: `${prefix}health_ready`, + help: "1 when this replica reports ready (after hysteresis), else 0", + registers: [this.register], + }); + this.healthCheckDuration = new client.Histogram({ + name: `${prefix}health_check_duration_seconds`, + help: "Duration of background health-indicator checks", + labelNames: ["indicator"], + buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 3], + registers: [this.register], + }); + + // ── Dual-write / consistency verifier (issue #404) ─────────────────────── + this.intentsDualWriteFailuresTotal = new client.Counter({ + name: `${prefix}intents_dual_write_failures_total`, + help: "Postgres mirror writes that failed while INTENTS_STORE=dual", + labelNames: ["operation"], + registers: [this.register], + }); + + this.intentsStoreMismatches = new client.Gauge({ + name: `${prefix}intents_store_mismatches`, + help: "Mismatches between the memory and Postgres intent stores found by the last verifier run", + labelNames: ["kind"], + registers: [this.register], + }); + + this.intentsStoreMismatchesTotal = new client.Counter({ + name: `${prefix}intents_store_mismatches_total`, + help: "Cumulative mismatches found by the dual-write consistency verifier", + labelNames: ["kind"], + registers: [this.register], + }); + + this.intentsStoreVerifierRunsTotal = new client.Counter({ + name: `${prefix}intents_store_verifier_runs_total`, + help: "Completed dual-write consistency verifier runs", + registers: [this.register], + }); + + // ── Contract version gating (issue #402) ───────────────────────────────── + this.contractVersionSupported = new client.Gauge({ + name: `${prefix}contract_version_supported`, + help: "1 when the deployed contract WASM maps to a supported ABI, 0 when writes are blocked", + labelNames: ["contract"], + registers: [this.register], + }); + + this.contractUpgradesTotal = new client.Counter({ + name: `${prefix}contract_upgrades_total`, + help: "Contract WASM upgrades detected, by detection source (poll | event)", + labelNames: ["contract", "source"], + registers: [this.register], + }); + + this.contractWritesBlockedTotal = new client.Counter({ + name: `${prefix}contract_writes_blocked_total`, + help: "On-chain writes refused because the contract version is unsupported", + labelNames: ["contract"], + registers: [this.register], + }); + + // ── Source-chain deposit verification (issue #403) ────────────────────── + this.srcVerificationsTotal = new client.Counter({ + name: `${prefix}src_verifications_total`, + help: "Source-deposit verification outcomes, by chain and status", + labelNames: ["chain", "status"], + registers: [this.register], + }); + + this.srcVerificationErrorsTotal = new client.Counter({ + name: `${prefix}src_verification_errors_total`, + help: "Source-deposit verification attempts that failed with an RPC error", + labelNames: ["chain", "reason"], + registers: [this.register], + }); + + this.srcVerificationQueueSize = new client.Gauge({ + name: `${prefix}src_verification_queue_size`, + help: "Open intents awaiting (re-)verification of their source deposit", + registers: [this.register], + }); + + // ── Outbox relay (issue #396) ─────────────────────────────────────────── + this.outboxRelayOutcomes = new client.Counter({ + name: `${prefix}outbox_relay_outcomes_total`, + help: "Outbox rows processed by the relay, by outcome (submitted|simulated|confirmed|retry|dead)", + labelNames: ["outcome"], + registers: [this.register], + }); + + this.outboxDeadTotal = new client.Counter({ + name: `${prefix}outbox_dead_total`, + help: "Outbox rows moved to dead after exhausting OUTBOX_MAX_ATTEMPTS (page on any increase)", + registers: [this.register], + }); + + this.outboxBacklog = new client.Gauge({ + name: `${prefix}outbox_rows`, + help: "Current number of outbox rows by status", + labelNames: ["status"], + registers: [this.register], + }); + + // ── Slashing saga (issue #397) ────────────────────────────────────────── + this.slashTransitions = new client.Counter({ + name: `${prefix}slash_pipeline_transitions_total`, + help: "Pending-slash state transitions, by target state and reason", + labelNames: ["to_state", "reason"], + registers: [this.register], + }); + + // ── Channel pool (issue #473) ─────────────────────────────────────────── + this.channelLeaseWaitTime = new client.Histogram({ + name: `${prefix}channel_lease_wait_seconds`, + help: "Seconds spent waiting for a channel lease", + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5], + registers: [this.register], + }); + + this.channelBadSeqResyncs = new client.Counter({ + name: `${prefix}channel_bad_seq_resyncs_total`, + help: "Channel reconnects forced by a bad sequence number", + registers: [this.register], + }); + + this.channelPoolUtilisation = new client.Gauge({ + name: `${prefix}channel_pool_utilisation`, + help: "Fraction (0..1) of the channel pool currently leased", + registers: [this.register], + }); + + // ── Transaction confirmation outcomes (issue #385) ────────────────────── + this.txConfirmationOutcomes = new client.Counter({ + name: `${prefix}tx_confirmation_outcomes_total`, + help: "Tracked transaction outcomes, by status", + labelNames: ["status"], + registers: [this.register], + }); + + // ── Fee-bump escalations (issue #454) ─────────────────────────────────── + this.txFeeBumpTotal = new client.Counter({ + name: `${prefix}tx_fee_bump_total`, + help: "Fee-bump transactions built, by inclusion-fee percentile", + labelNames: ["percentile"], + registers: [this.register], + }); + + this.txFeeBumpCeilingHits = new client.Counter({ + name: `${prefix}tx_fee_bump_ceiling_hits_total`, + help: "Refusals to escalate past the configured max fee (stroops/op)", + registers: [this.register], + }); + } + + /** Registers the source sampled for `vortex_jobs_queue_depth` on each scrape. */ + setQueueDepthProvider( + provider: () => Promise>, + ): void { + this.queueDepthProvider = provider; + } + + onModuleInit() { + const prefix = "vortex_"; + client.collectDefaultMetrics({ register: this.register, prefix }); + } + + async metrics(): Promise { + return this.register.metrics(); + } + + contentType(): string { + return this.register.contentType; + } + + incIntentStateTransition(from: string, to: string) { + this.intentStateTransitions.inc({ from_state: from, to_state: to }); + } + + incWsConnection() { + this.wsConnections.inc(); + } + + decWsConnection() { + this.wsConnections.dec(); + } + + /** + * Record one sweeper cycle's expired count and duration. + * Called by IntentsSweeperService at the end of every sweep() invocation. + */ + recordSweep(expiredCount: number, durationMs: number): void { + this.sweeperExpiredTotal.inc(expiredCount); + this.sweeperSweepDurationMs.observe(durationMs); + } + + /** + * Items the safety sweep had to settle because a deadline job did not. + * Called by IntentsSweeperService at the end of `sweep({ safety: true })`. + */ + recordSafetyCatch(count: number): void { + if (count > 0) this.sweeperSafetyCaughtTotal.inc(count); + } + + /** + * Observe intent-create latency (SLO SLI, issue #480). + * Call from the create path with handler duration in seconds. + */ + observeIntentCreate(durationSeconds: number, route = "POST /api/v1/intents"): void { + this.intentCreateDuration.observe({ route }, durationSeconds); + } + + /** + * Observe WS end-to-end delivery latency (SLO SLI, issue #480). + * Call from the gateway broadcast path with queue-to-send duration. + */ + observeWsDelivery(durationSeconds: number): void { + this.wsDeliveryDuration.observe(durationSeconds); + } + + /** Set current event-ingestion lag in seconds (SLO SLI, issue #480). */ + setIngestionLag(lagSeconds: number): void { + this.eventIngestionLag.set(lagSeconds); + } + + /** Observe fill-to-confirmation latency in seconds (SLO SLI, issue #480). */ + observeTxConfirmation(durationSeconds: number): void { + this.txConfirmationDuration.observe(durationSeconds); + } + + // ── WS capability-filter helpers (issue #436) ──────────────────────────── + + /** Record a WS event delivered to an authenticated solver (post-filter). */ + incWsDelivered(solverAddress: string): void { + this.wsEventsDeliveredTotal.inc({ solver: solverAddress.slice(0, 12) }); + } + + /** Record a WS event suppressed for a solver by the capability filter. */ + incWsFiltered(solverAddress: string): void { + this.wsEventsFilteredTotal.inc({ solver: solverAddress.slice(0, 12) }); + } + + // ── Restore-transaction helpers (issue #394) ────────────────────────────── + + incSorobanRestore(result: "success" | "failed"): void { + this.sorobanRestoreTotal.inc({ result }); + } + + observeRestoreFee(stroops: number): void { + this.sorobanRestoreFeeStroops.observe(stroops); + } + + // ── Remote signer helpers (issue #400) ──────────────────────────────────── + + observeSignerCall(backend: string, operation: string, durationSeconds: number): void { + this.signerCallDurationSeconds.observe({ backend, operation }, durationSeconds); + } + + // ── Solver-registry event ingestion helpers (issue #399) ───────────────── + + incSolverRegistryEvent(eventType: string): void { + this.solverRegistryEventsTotal.inc({ event_type: eventType }); + } + + /** + * Record one resolved shadow-mode comparison (issue #401). + * + * `expected` is the off-chain verdict and `outcome` the simulated one, so + * the pair required by the issue stays queryable from PromQL: + * `...{expected="ok",outcome="rejected"}` is the "contract would have + * refused a transition we committed" case, and the reverse label pair is the + * "we refused something the contract allows" case. Cardinality is bounded at + * 5 transitions x 2 expected x 4 outcomes. + * + * `outcome` is `"unavailable"` when the simulation never produced a verdict + * (unconfigured contract, RPC unreachable) so that case stays + * distinguishable in PromQL from a contract that actively said no. + */ + recordShadowComparison(transition: string, expected: string, outcome: string): void { + this.shadowComparisons.inc({ transition, expected, outcome }); + } + + /** Record one classified shadow-mode divergence (issue #401). */ + recordShadowDivergence(transition: string, reason: string): void { + this.shadowDivergences.inc({ transition, reason }); + } + + /** Record one shadow-mode observation dropped by the bounded queue. */ + recordShadowDrop(): void { + this.shadowDropped.inc(); + } + + /** Publish the current shadow queue depth. */ + setShadowQueueDepth(depth: number): void { + this.shadowQueueDepth.set(depth); + } + + /** + * Record that this replica acquired leadership for `workerName`. + * Sets the is_leader gauge to 1 and increments the acquisition counter. + */ + recordLeadershipAcquired(workerName: string): void { + this.leaderElectionIsLeader.set({ worker: workerName }, 1); + this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "acquired" }); + } + + /** + * Record that this replica lost leadership for `workerName`. + * Sets the is_leader gauge to 0 and increments the lost counter. + */ + recordLeadershipLost(workerName: string): void { + this.leaderElectionIsLeader.set({ worker: workerName }, 0); + this.leaderElectionChangesTotal.inc({ worker: workerName, transition: "lost" }); + } + + /** Count a Postgres mirror write that failed in dual-write mode. */ + recordDualWriteFailure(operation: string): void { + this.intentsDualWriteFailuresTotal.inc({ operation }); + } + + /** Publish one consistency-verifier run's mismatch counts, keyed by kind. */ + recordStoreVerification(mismatches: Record): void { + this.intentsStoreVerifierRunsTotal.inc(); + for (const [kind, count] of Object.entries(mismatches)) { + this.intentsStoreMismatches.set({ kind }, count); + if (count > 0) this.intentsStoreMismatchesTotal.inc({ kind }, count); + } + } + + setContractVersionSupported(contract: string, supported: boolean): void { + this.contractVersionSupported.set({ contract }, supported ? 1 : 0); + } + + recordContractUpgrade(contract: string, source: "poll" | "event"): void { + this.contractUpgradesTotal.inc({ contract, source }); + } + + recordContractWriteBlocked(contract: string): void { + this.contractWritesBlockedTotal.inc({ contract }); + } + + recordSrcVerification(chain: string, status: string): void { + this.srcVerificationsTotal.inc({ chain, status }); + } + + recordSrcVerificationError(chain: string, reason: "rate_limited" | "rpc_error"): void { + this.srcVerificationErrorsTotal.inc({ chain, reason }); + } + + setSrcVerificationQueueSize(size: number): void { + this.srcVerificationQueueSize.set(size); + } + + /** One outbox row outcome; `dead` also feeds the alerting counter. */ + recordOutboxOutcome(outcome: "submitted" | "simulated" | "confirmed" | "retry" | "dead"): void { + this.outboxRelayOutcomes.inc({ outcome }); + if (outcome === "dead") this.outboxDeadTotal.inc(); + } + + setOutboxBacklog(counts: Record): void { + for (const [status, count] of Object.entries(counts)) { + this.outboxBacklog.set({ status }, count); + } + } + + recordSlashTransition(toState: string, reason = "none"): void { + this.slashTransitions.inc({ to_state: toState, reason }); + } + + // ── Anti-griefing helpers (issue #453) ──────────────────────────────────── + + /** Record one anti-griefing enforcement state-machine transition. */ + recordGriefingTransition(solverAddress: string, fromState: string, toState: string): void { + this.griefingStateTransitionsTotal.inc({ + solver: solverAddress.slice(0, 12), + from_state: fromState, + to_state: toState, + }); + } + + /** Update the rolling unfilled-accept ratio for a solver under enforcement. */ + setGriefingRatio(solverAddress: string, ratio: number): void { + this.griefingUnfilledRatio.set({ solver: solverAddress.slice(0, 12) }, ratio); + } + + /** Set the count of solvers currently under enforcement. */ + setGriefingEnforcedCount(count: number): void { + this.griefingEnforcedSolvers.set(count); + } + + /** + * Update per-solver enforcement state gauges. + * + * Sets the named state label to 1 and all other enforcement states to 0 + * so dashboards can query `{state="suspended"}` without stale series. + */ + setGriefingEnforcementState(solverAddress: string, state: string): void { + const s = solverAddress.slice(0, 12); + for (const st of ["ok", "cooldown", "reduced-concurrency", "suspended"]) { + this.griefingEnforcementState.set({ solver: s, state: st }, st === state ? 1 : 0); + } + } + + /** + * Update the effective concurrency cap for a solver. + * Pass 0 when no limit is active (state is not "reduced-concurrency"). + */ + setGriefingConcurrencyLimit(solverAddress: string, limit: number): void { + this.griefingConcurrencyLimit.set({ solver: solverAddress.slice(0, 12) }, limit); + } +} diff --git a/src/prisma/prisma-replica.service.spec.ts b/src/prisma/prisma-replica.service.spec.ts index 7aef7731..a9b2ce00 100644 --- a/src/prisma/prisma-replica.service.spec.ts +++ b/src/prisma/prisma-replica.service.spec.ts @@ -71,6 +71,7 @@ describe("PrismaReplicaService (#411)", () => { replica1 = makeMockPrismaClient(100); replica2 = makeMockPrismaClient(200); let callCount = 0; + // eslint-disable-next-line @typescript-eslint/no-var-requires jest.spyOn(require("@prisma/client"), "PrismaClient").mockImplementation(() => { return callCount++ === 0 ? replica1 : replica2; }); diff --git a/src/solvers/leaderboard-query.ts b/src/solvers/leaderboard-query.ts index 360c6f0c..d9d01421 100644 --- a/src/solvers/leaderboard-query.ts +++ b/src/solvers/leaderboard-query.ts @@ -1,33 +1,10 @@ -import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "../common/pagination"; - -/** - * Query parameters for `GET /api/v1/solvers/leaderboard` (#412). - * - * Uses opaque keyset cursors for stable, efficient paging. - * The leaderboard is ordered by `(fillsCompleted DESC, address ASC)`. - */ export type LeaderboardSortKey = "fills" | "reputation"; export interface LeaderboardQuery { - /** Opaque cursor from the previous page's `nextCursor`. */ cursor?: string; - /** Page size — defaults to DEFAULT_PAGE_SIZE, capped at MAX_PAGE_SIZE. */ limit?: number; - /** Filter to solvers supporting this chain. */ chain?: string; /** - * @deprecated Use cursor instead. Capped at MAX_OFFSET. - * Included for backward-compatibility; callers receive a Deprecation header. - */ - offset?: number; -} - -/** - * Normalise a {@link LeaderboardQuery} limit to a safe integer. - */ -export function resolveLimit(query: LeaderboardQuery): number { - const raw = typeof query.limit === "number" ? query.limit : DEFAULT_PAGE_SIZE; - return Math.max(1, Math.min(raw, MAX_PAGE_SIZE)); * Primary sort key for the leaderboard (issue #444). * "fills" — existing behaviour, sorted by fillsCompleted desc. * "reputation" — sorted by the Reputation v2 score (see RFC 0003), diff --git a/src/solvers/reputation.service.spec.ts b/src/solvers/reputation.service.spec.ts index c8435f9c..09316e18 100644 --- a/src/solvers/reputation.service.spec.ts +++ b/src/solvers/reputation.service.spec.ts @@ -170,7 +170,9 @@ const fcFill = fcTimestamp().chain((ts) => const fcSlash = fcTimestamp().chain((ts) => fc.record({ timestamp: fc.constant(ts), - severity: fc.float({ min: 0.1, max: 2, noNaN: true }), + // `min` must be exactly representable as a 32-bit float; 0.1 is not, + // so round it the way fast-check itself prescribes. + severity: fc.float({ min: Math.fround(0.1), max: 2, noNaN: true }), disputeStatus: fc.constantFrom< "none" | "disputed" | "resolved-upheld" | "resolved-reversed" | undefined >(undefined, "none", "disputed", "resolved-upheld", "resolved-reversed"), diff --git a/src/solvers/reputation.service.ts b/src/solvers/reputation.service.ts index 08730665..a36bda7f 100644 --- a/src/solvers/reputation.service.ts +++ b/src/solvers/reputation.service.ts @@ -231,18 +231,11 @@ function betaFunction(a: number, b: number): number { return Math.exp(logGamma(a) + logGamma(b) - logGamma(a + b)); } -function regularizedIncompleteBeta(p: number, a: number, b: number): number { - // B(a,b; p) / B(a,b) via the continued-fraction form (Numerical Recipes §6.4). - if (p <= 0) return 0; - if (p >= 1) return 1; - const bt = - Math.exp( - logGamma(a + b) - - logGamma(a) - - logGamma(b) + - a * Math.log(p) + - b * Math.log(1 - p), - ); +/** + * Continued fraction for the incomplete beta function (Numerical Recipes + * §6.4 `betacf`), evaluated at `(a, b, p)`. + */ +function betaContinuedFraction(a: number, b: number, p: number): number { const fpmin = 1e-300; const maxIt = 200; const eps = 3e-12; @@ -273,7 +266,32 @@ function regularizedIncompleteBeta(p: number, a: number, b: number): number { h *= del; if (Math.abs(del - 1) < eps) break; } - return p < (a + 1) / (a + b + 2) ? (bt * h) / a : 1 - (bt * h) / b; + return h; +} + +function regularizedIncompleteBeta(p: number, a: number, b: number): number { + // B(a,b; p) / B(a,b) via the continued-fraction form (Numerical Recipes §6.4). + if (p <= 0) return 0; + if (p >= 1) return 1; + const bt = + Math.exp( + logGamma(a + b) - + logGamma(a) - + logGamma(b) + + a * Math.log(p) + + b * Math.log(1 - p), + ); + // Both branches share the prefactor `bt`, but the second one relies on the + // symmetry I_x(a,b) = 1 − I_{1−x}(b,a): its continued fraction must be + // re-evaluated with the parameters swapped and the argument mirrored. + // Reusing the first branch's value here silently produced garbage for every + // p ≥ (a+1)/(a+b+2) with asymmetric (a, b) — e.g. I_0.5(1, 2) returned + // 0.625 instead of 0.75, and the reputation fill-rate quantile came out + // backwards for well-established performers. + if (p < (a + 1) / (a + b + 2)) { + return (bt * betaContinuedFraction(a, b, p)) / a; + } + return 1 - (bt * betaContinuedFraction(b, a, 1 - p)) / b; } function betaQuantile(q: number, a: number, b: number): number { @@ -294,12 +312,14 @@ function betaQuantile(q: number, a: number, b: number): number { let hi = 1; for (let guard = 0; guard < 30; guard++) { const fx = regularizedIncompleteBeta(x, a, b) - q; + // Beta density at x: x^(a−1) (1−x)^(b−1) / B(a,b). The previous form put + // the logΓ combination inside the exponent AND divided by B(a,b); those + // logΓ terms already fold in 1/B(a,b), so the slope was inflated by a + // factor 1/B, which threw Newton's step across the root and made the + // inverse quantile wander for well-established (large a,b) performers. const df = Math.exp( - logGamma(a + b) - - logGamma(a) - - logGamma(b) + - (a - 1) * Math.log(Math.max(x, 1e-300)) + + (a - 1) * Math.log(Math.max(x, 1e-300)) + (b - 1) * Math.log(Math.max(1 - x, 1e-300)), ) / betaFunction(a, b); if (fx > 0) hi = x; diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index e510d0b4..2e1bd1ca 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -1,660 +1,3 @@ -import { - BadRequestException, - Body, - Controller, - Get, - HttpCode, - NotFoundException, - Param, - Post, - Put, - Query, - Res, -} from "@nestjs/common"; -import { ApiOperation, ApiParam, ApiQuery, ApiTags } from "@nestjs/swagger"; -import type { Response } from "express"; -import { SolversService } from "./solvers.service"; -import { RegisterSolverDto } from "./dto/register-solver.dto"; -import { UpdateSolverDto } from "./dto/update-solver.dto"; -import { SolverRecord } from "./solvers.types"; -import { resolveLimit, LeaderboardQuery } from "./leaderboard-query"; -import { - PaginatedResponse, - encodeCursor, - decodeCursor, - hashFilter, - getCursorSecret, - DEFAULT_PAGE_SIZE, - MAX_OFFSET, -} from "../common/pagination"; - -/** - * REST controller for solver management (#412 pagination). - * - * - GET /solvers — list all solvers - * - GET /solvers/leaderboard — paginated leaderboard (keyset) - * - GET /solvers/:addr — get by address - * - POST /solvers — register - * - PUT /solvers/:addr — update profile - * - GET /solvers/:addr/fills — fill history (keyset) - */ -@ApiTags("solvers") -@Controller("api/v1/solvers") -export class SolversController { - constructor(private readonly solversService: SolversService) {} - - // ─── List / leaderboard ─────────────────────────────────────────────────── - - @Get() - @ApiOperation({ summary: "List all solvers" }) - async listAll(): Promise { - return this.solversService.getAll(); - } - - /** - * GET /api/v1/solvers/leaderboard — keyset-paginated solver leaderboard (#412). - * - * Ordered by (fillsCompleted DESC, address ASC) for stability. - */ - @Get("leaderboard") - @ApiOperation({ summary: "Solver leaderboard (keyset-paginated)" }) - @ApiQuery({ name: "limit", required: false, type: Number }) - @ApiQuery({ name: "cursor", required: false, type: String }) - @ApiQuery({ name: "chain", required: false, type: String }) - @ApiQuery({ name: "offset", required: false, type: Number, deprecated: true }) - async leaderboard( - @Query("limit") rawLimit?: string, - @Query("cursor") cursor?: string, - @Query("chain") chain?: string, - @Query("offset") rawOffset?: string, - @Res({ passthrough: true }) res?: Response, - ): Promise> { - const query: LeaderboardQuery = { - limit: parseInt(rawLimit ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE, - cursor, - chain, - offset: rawOffset !== undefined ? parseInt(rawOffset, 10) : undefined, - }; - - const limit = resolveLimit(query); - const secret = getCursorSecret(); - const filterObj: Record = {}; - if (chain) filterObj.chain = chain; - const filterHash = hashFilter(filterObj); - - // Deprecated offset. - let offset: number | undefined; - if (query.offset !== undefined && !Number.isNaN(query.offset)) { - if (query.offset > MAX_OFFSET) { - throw new BadRequestException(`offset exceeds maximum of ${MAX_OFFSET}; use cursor`); - } - res?.setHeader("Deprecation", "true"); - res?.setHeader("Link", "; rel=\"successor-version\""); - offset = query.offset; - } - - // Decode cursor. - let cursorPayload: { createdAt: number; id: string } | undefined; - if (cursor) { - cursorPayload = decodeCursor(cursor, secret, filterHash); - } - - // Load all solvers and sort by (fillsCompleted DESC, address ASC). - let all = await this.solversService.getAll(); - if (chain) { - all = all.filter((s) => s.supportedChains.includes(chain as SolverRecord["supportedChains"][number])); - } - all = all.filter((s) => s.isActive); - all.sort((a, b) => { - if (b.fillsCompleted !== a.fillsCompleted) return b.fillsCompleted - a.fillsCompleted; - return a.address.localeCompare(b.address); - }); - - // Seek: the cursor encodes (fillsCompleted as createdAt, address as id). - let startIdx = offset ?? 0; - if (cursorPayload) { - const pos = all.findIndex( - (s) => - s.fillsCompleted < cursorPayload!.createdAt || - (s.fillsCompleted === cursorPayload!.createdAt && s.address > cursorPayload!.id), - ); - startIdx = pos === -1 ? all.length : pos; - } - - const page = all.slice(startIdx, startIdx + limit); - const hasMore = startIdx + limit < all.length; - - let nextCursor: string | null = null; - if (hasMore && page.length > 0) { - const last = page[page.length - 1]; - nextCursor = encodeCursor( - { createdAt: last.fillsCompleted, id: last.address, filterHash }, - secret, - ); - } - - return new PaginatedResponse(page, nextCursor); - } - - // ─── Fill history ───────────────────────────────────────────────────────── - - /** - * GET /api/v1/solvers/:addr/fills — keyset-paginated fill history (#412). - * - * Ordered by (timestamp DESC, slashId ASC). - */ - @Get(":addr/fills") - @ApiOperation({ summary: "Solver fill / slash history (keyset-paginated)" }) - @ApiQuery({ name: "limit", required: false, type: Number }) - @ApiQuery({ name: "cursor", required: false, type: String }) - @ApiQuery({ name: "offset", required: false, type: Number, deprecated: true }) - async fillHistory( - @Param("addr") addr: string, - @Query("limit") rawLimit?: string, - @Query("cursor") cursor?: string, - @Query("offset") rawOffset?: string, - @Res({ passthrough: true }) res?: Response, - ): Promise> { - const solver = await this.solversService.get(addr); - if (!solver) throw new NotFoundException(`Solver ${addr} not found`); - - const limit = Math.min(parseInt(rawLimit ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE, 100); - const secret = getCursorSecret(); - const filterHash = hashFilter({ addr }); - - let offset: number | undefined; - if (rawOffset !== undefined) { - const parsedOffset = parseInt(rawOffset, 10); - if (!Number.isNaN(parsedOffset)) { - if (parsedOffset > MAX_OFFSET) { - throw new BadRequestException(`offset exceeds maximum of ${MAX_OFFSET}`); - } - res?.setHeader("Deprecation", "true"); - offset = parsedOffset; - } - } - - let cursorPayload: { createdAt: number; id: string } | undefined; - if (cursor) { - cursorPayload = decodeCursor(cursor, secret, filterHash); - } - - const { records: all } = await this.solversService.getSlashHistory(addr, 1, 10_000); - all.sort((a, b) => b.timestamp - a.timestamp || a.slashId.localeCompare(b.slashId)); - - let startIdx = offset ?? 0; - if (cursorPayload) { - const pos = all.findIndex( - (r) => - r.timestamp < cursorPayload!.createdAt || - (r.timestamp === cursorPayload!.createdAt && r.slashId > cursorPayload!.id), - ); - startIdx = pos === -1 ? all.length : pos; - } - - const page = all.slice(startIdx, startIdx + limit); - const hasMore = startIdx + limit < all.length; - let nextCursor: string | null = null; - if (hasMore && page.length > 0) { - const last = page[page.length - 1]; - nextCursor = encodeCursor({ createdAt: last.timestamp, id: last.slashId, filterHash }, secret); - } - - return new PaginatedResponse(page, nextCursor); - } - - // ─── CRUD ───────────────────────────────────────────────────────────────── - - @Get(":addr") - @ApiOperation({ summary: "Get solver by address" }) - @ApiParam({ name: "addr", description: "Solver Stellar address" }) - async getByAddress(@Param("addr") addr: string): Promise { - const solver = await this.solversService.get(addr); - if (!solver) throw new NotFoundException(`Solver ${addr} not found`); - return solver; - } - - @Post() - @HttpCode(201) - @ApiOperation({ summary: "Register a solver" }) - async register(@Body() dto: RegisterSolverDto): Promise { - return this.solversService.register({ - address: dto.address, - name: dto.name, - bondAmount: dto.bondAmount, - avgFillTime: dto.avgFillTime, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - isActive: true, - }); - } - - @Put(":addr") - @ApiOperation({ summary: "Update solver profile" }) - async update( - @Param("addr") addr: string, - @Body() dto: UpdateSolverDto, - ): Promise { - const updated = await this.solversService.update(addr, dto); - if (!updated) throw new NotFoundException(`Solver ${addr} not found`); - return updated; - } - - @Post(":addr/deactivate") - @HttpCode(200) - @ApiOperation({ summary: "Deactivate a solver" }) - async deactivate(@Param("addr") addr: string): Promise { - const result = await this.solversService.deactivate(addr); - if (!result) throw new NotFoundException(`Solver ${addr} not found`); - return result; - } - - @Post(":addr/reactivate") - @HttpCode(200) - @ApiOperation({ summary: "Reactivate a solver" }) - async reactivate(@Param("addr") addr: string): Promise { - const result = await this.solversService.reactivate(addr); - if (!result) throw new NotFoundException(`Solver ${addr} not found`); - return result; - } -} -import { - BadRequestException, - Body, - Controller, - ForbiddenException, - Get, - NotFoundException, - Optional, - Param, - Patch, - Post, - Query, -} from "@nestjs/common"; -import { - ApiNotFoundResponse, - ApiOperation, - ApiQuery, - ApiTags, -} from "@nestjs/swagger"; -import { ConfigService } from "@nestjs/config"; -import { IntentsService } from "../intents/intents.service"; -import { - buildDisputeMessage, - buildRegisterMessage, - buildSolverStatusMessage, - buildUpdateSolverMessage, - verifyStellarSignature, -} from "../common/stellar-signature"; -import { SolversService, LeaderboardWindow } from "./solvers.service"; -import { SolverGriefingService } from "./solver-griefing.service"; -import { applyGriefingPenalty } from "./solver-griefing.types"; -import { ListIntentsDto } from "../intents/dto/list-intents.dto"; -import { AppConfig } from "../config/configuration"; -import { isCanaryIntent } from "../common/canary"; -import { IntentCapabilityIndex } from "../intents/solver-intent-matcher"; -import { RegisterSolverDto } from "./dto/register-solver.dto"; -import { UpdateSolverDto } from "./dto/update-solver.dto"; -import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; - -const WINDOW_SECONDS: Record, number> = { - "24h": 24 * 60 * 60, - "7d": 7 * 24 * 60 * 60, - "30d": 30 * 24 * 60 * 60, -}; - -@ApiTags("solvers") -@Controller("api/v1/solvers") -export class SolversController { - constructor( - private readonly solversService: SolversService, - private readonly intentsService: IntentsService, - private readonly intentIndex: IntentCapabilityIndex, - @Optional() private readonly griefingService: SolverGriefingService | null, - config: ConfigService, - ) { - this.canary = new Set(config.get("canaryAddresses", { infer: true }) ?? []); - } - - /** Canary addresses (issue #496) — excluded from every leaderboard. */ - private readonly canary: ReadonlySet; - - @Post() - async register(@Body() dto: RegisterSolverDto) { - verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.proofSignature); - - const onchainEnabled = (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true"; - - if (onchainEnabled) { - // Issue #399: when on-chain intents are enabled, POST /solvers is a - // metadata-only endpoint. Bond amount is authoritative on-chain; the - // REST endpoint may not set it. Supported chains/tokens and name are - // still accepted and merged into any existing record. - const existing = await this.solversService.get(dto.address); - if (existing) { - // Update metadata fields only — bond unchanged. - return this.solversService.register({ - address: dto.address, - name: dto.name, - bondAmount: existing.bondAmount, // preserve on-chain bond - avgFillTime: dto.avgFillTime, - isActive: existing.isActive, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); - } - // First-time metadata registration (bond will be set by on-chain event). - return this.solversService.register({ - address: dto.address, - name: dto.name, - bondAmount: "0", // bond is always set by chain events when ONCHAIN_INTENTS_ENABLED - avgFillTime: dto.avgFillTime, - isActive: true, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); - } - - return this.solversService.register({ - address: dto.address, - name: dto.name, - bondAmount: dto.bondAmount, - avgFillTime: dto.avgFillTime, - isActive: true, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); - } - - @Get("leaderboard") - @ApiOperation({ - summary: "Windowed solver leaderboard", - description: - "Returns the ranked solver list for a specific window. This endpoint is intended for recent-performance visibility and does not alter the legacy all-time leaderboard.", - }) - @ApiQuery({ name: "window", required: false, enum: ["24h", "7d", "30d", "all"], description: "Time window over which to compute rankings." }) - async getLeaderboard(@Query("window") window: string = "all") { - const resolvedWindow = this.normalizeWindow(window); - const solvers = (await this.solversService.getAll()).filter((s) => !this.canary.has(s.address)); - const intents = (await this.intentsService.getAll()).filter((i) => !isCanaryIntent(i, this.canary)); - const now = Math.floor(Date.now() / 1000); - const cutoff = resolvedWindow === "all" ? 0 : now - WINDOW_SECONDS[resolvedWindow]; - - const ranked = solvers - .map((solver) => { - const recentIntents = intents.filter((intent) => { - if (intent.solver !== solver.address || intent.state !== "filled") return false; - const timestamp = intent.filledAt ?? intent.createdAt; - return resolvedWindow === "all" || timestamp >= cutoff; - }); - - const slashedRecent = intents.filter((intent) => { - if (intent.solver !== solver.address || intent.state !== "slashed") return false; - const timestamp = intent.slashedAt ?? intent.createdAt; - return resolvedWindow === "all" || timestamp >= cutoff; - }); - - const fillsCompleted = recentIntents.length; - const fillsFailed = slashedRecent.length; - const total = fillsCompleted + fillsFailed; - const successRate = total > 0 ? fillsCompleted / total : 0; - const ageDays = Math.max(0, (now - solver.registeredAt) / 86400); - const rawReputation = Number( - (successRate * Math.exp(-ageDays / 180)).toFixed(4), - ); - // Apply griefing penalty: suspended → 0, reduced-concurrency → ×0.5, - // cooldown → ×0.8, ok → ×1.0 (issue #453 criterion 2). - const griefingState = this.griefingService?.getRecord(solver.address)?.state ?? "ok"; - const reputationScore = applyGriefingPenalty(rawReputation, griefingState); - - return { - address: solver.address, - name: solver.name, - fillsCompleted, - fillsFailed, - successRate: Number(successRate.toFixed(4)), - reputationScore, - griefingState, - totalVolume: recentIntents - .reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n) - .toString(), - avgFillTime: recentIntents.length - ? Math.round( - recentIntents.reduce((sum, intent) => { - if (!intent.filledAt) return sum; - return sum + (intent.filledAt - intent.createdAt); - }, 0) / recentIntents.length, - ) - : 0, - bondAmount: solver.bondAmount, - isActive: solver.isActive, - window: resolvedWindow, - }; - }) - .filter((entry) => entry.fillsCompleted > 0 || entry.fillsFailed > 0 || resolvedWindow === "all") - .sort((a, b) => b.reputationScore - a.reputationScore || b.fillsCompleted - a.fillsCompleted); - - return { solvers: ranked, count: ranked.length, window: resolvedWindow }; - } - - @Get() - async getLegacyLeaderboard() { - const solvers = (await this.solversService.getAll()) - .filter((s) => !this.canary.has(s.address)) - .sort((a, b) => b.fillsCompleted - a.fillsCompleted); - return { solvers, count: solvers.length }; - } - - @Get(":address/eligible-intents") - async getEligibleIntents(@Param("address") address: string, @Query() dto: ListIntentsDto) { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - if (!solver.isActive) throw new ForbiddenException("Solver is not active"); - - // Use the capability index for O(supported-chains × supported-tokens) - // lookup instead of scanning all open intents (issue #436). - const eligible = this.intentIndex.getEligibleFor(solver); - - const limit = Math.min(dto.limit ?? 20, 100); - const offset = dto.offset ?? 0; - - if ((dto.limit ?? 20) > 100) { - throw new BadRequestException("Limit exceeds maximum allowed value of 100"); - } - - const page = eligible.slice(offset, offset + limit); - return { intents: page, total: eligible.length, count: eligible.length, limit, offset }; - } - - @Get(":address") - - async getSolver(@Param("address") address: string) { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - return solver; - } - - @Get(":address/stats") - async getSolverStats(@Param("address") address: string, @Query("window") window?: string) { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - - const resolvedWindow = this.normalizeWindow(window ?? "all"); - const intents = await this.intentsService.getAll(); - const now = Math.floor(Date.now() / 1000); - const cutoff = resolvedWindow === "all" ? 0 : now - WINDOW_SECONDS[resolvedWindow]; - - const recentIntents = intents.filter((intent) => { - if (intent.solver !== address) return false; - const timestamp = intent.state === "filled" ? intent.filledAt ?? intent.createdAt : intent.slashedAt ?? intent.createdAt; - return resolvedWindow === "all" || timestamp >= cutoff; - }); - - const fillsCompleted = recentIntents.filter((intent) => intent.state === "filled").length; - const fillsFailed = recentIntents.filter((intent) => intent.state === "slashed").length; - const total = fillsCompleted + fillsFailed; - const successRate = total > 0 ? fillsCompleted / total : 0; - const ageDays = Math.max(0, (now - solver.registeredAt) / 86400); - const rawReputation = Number((successRate * Math.exp(-ageDays / 180)).toFixed(4)); - // Apply griefing penalty to reputation score (issue #453 criterion 2). - const griefingState = this.griefingService?.getRecord(address)?.state ?? "ok"; - const reputationScore = applyGriefingPenalty(rawReputation, griefingState); - - return { - address: solver.address, - name: solver.name, - fillsCompleted, - fillsFailed, - successRate: Number(successRate.toFixed(4)), - reputationScore, - griefingState, - totalVolume: recentIntents - .filter((intent) => intent.state === "filled") - .reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n) - .toString(), - avgFillTime: recentIntents.filter((intent) => intent.state === "filled" && intent.filledAt != null).length - ? Math.round( - recentIntents - .filter((intent) => intent.state === "filled" && intent.filledAt != null) - .reduce((sum, intent) => sum + (intent.filledAt! - intent.createdAt), 0) / - recentIntents.filter((intent) => intent.state === "filled" && intent.filledAt != null).length, - ) - : 0, - bondAmount: solver.bondAmount, - window: resolvedWindow, - }; - } - - @Get(":address/slashes") - async getSlashHistory(@Param("address") address: string, @Query("page") page = "1", @Query("pageSize") pageSize = "25") { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - - const pageNumber = Number(page) || 1; - const pageSizeNumber = Number(pageSize) || 25; - return this.solversService.getSlashHistory(address, pageNumber, pageSizeNumber); - } - - @Post(":address/slashes/:slashId/dispute") - async submitDispute( - @Param("address") address: string, - @Param("slashId") slashId: string, - @Body() dto: { reason: string; evidenceReference?: string; signature: string }, - ) { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - - verifyStellarSignature( - address, - buildDisputeMessage(slashId, address, dto.reason), - dto.signature, - ); - - const record = await this.solversService.submitDispute( - address, - slashId, - dto.reason, - dto.evidenceReference, - ); - if (!record) throw new NotFoundException("Slash record not found"); - return record; - } - - @Post(":address/slashes/:slashId/dispute/resolve") - async resolveDispute( - @Param("address") address: string, - @Param("slashId") slashId: string, - @Body() dto: { resolution: "resolved-upheld" | "resolved-reversed"; reviewer?: string; note?: string }, - ) { - const solver = await this.solversService.get(address); - if (!solver) throw new NotFoundException("Solver not found"); - - const record = await this.solversService.resolveDispute( - address, - slashId, - dto.resolution, - dto.reviewer, - dto.note, - ); - if (!record) throw new NotFoundException("Slash record not found"); - return record; - } - - @Post(":address/deregister") - async deregisterSolver(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { - verifyStellarSignature(address, buildSolverStatusMessage("deregister", address), dto.signature); - - const solver = await this.solversService.deregister(address); - if (!solver) throw new NotFoundException("Solver not found"); - return { - ...solver, - withdrawalStatus: "pending", - withdrawalRequestedAt: Math.floor(Date.now() / 1000), - }; - } - - @Post(":address/deactivate") - async deactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { - verifyStellarSignature(address, buildSolverStatusMessage("deactivate", address), dto.signature); - - const solver = await this.solversService.deactivate(address); - if (!solver) throw new NotFoundException("Solver not found"); - return solver; - } - - @Post(":address/reactivate") - async reactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { - verifyStellarSignature(address, buildSolverStatusMessage("reactivate", address), dto.signature); - const solver = await this.solversService.reactivate(address); - if (!solver) throw new NotFoundException("Solver not found"); - return solver; - } - - /** - * PATCH /api/v1/solvers/:address — issue #273. - * - * Partial update of the solver's *mutable* profile fields. Requires an - * Ed25519 signature over `update-solver:
` from the solver's own - * key, so a third party cannot rewrite another solver's listing. - * - * Immutable fields (bond, fill counters, volume, registeredAt, isActive) are - * not present on `UpdateSolverDto`, so the global - * `ValidationPipe({ whitelist: true })` strips them from the body before the - * handler runs — they are silently ignored rather than rejected. - */ - @Patch(":address") - @ApiOperation({ - summary: "Update a solver's mutable profile fields", - description: - "Partial update of name, supportedChains, supportedTokens and avgFillTime. " + - "Requires an Ed25519 signature over the message `update-solver:
` " + - "produced by the solver's own key. Array fields are replaced wholesale. " + - "Immutable fields are silently ignored.", - }) - @ApiNotFoundResponse({ description: "Solver not found" }) - async update(@Param("address") address: string, @Body() dto: UpdateSolverDto) { - verifyStellarSignature(address, buildUpdateSolverMessage(address), dto.signature); - - const updated = await this.solversService.update(address, { - name: dto.name, - avgFillTime: dto.avgFillTime, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); - - if (!updated) throw new NotFoundException("Solver not found"); - return updated; - } - - - private normalizeWindow(window?: string): LeaderboardWindow { - const normalized = (window ?? "all").toLowerCase(); - if (normalized === "all" || normalized === "24h" || normalized === "7d" || normalized === "30d") { - return normalized as LeaderboardWindow; - } - throw new BadRequestException("Unsupported leaderboard window. Choose 24h, 7d, 30d, or all."); - } -} import { BadRequestException, Body, diff --git a/src/solvers/solvers.module.ts b/src/solvers/solvers.module.ts index b30c291b..5b437236 100644 --- a/src/solvers/solvers.module.ts +++ b/src/solvers/solvers.module.ts @@ -1,84 +1,54 @@ -import { Module, OnModuleInit, forwardRef } from "@nestjs/common"; -import { SolversController } from "./solvers.controller"; -import { SolversService } from "./solvers.service"; -import { SOLVERS_REPOSITORY } from "./solvers.repository"; -import { InMemorySolversRepository } from "./in-memory-solvers.repository"; -import { PrismaSolversRepository } from "./prisma-solvers.repository"; -import { PrismaService } from "../prisma/prisma.service"; -import { IntentsModule } from "../intents/intents.module"; -import { SolverGriefingService } from "./solver-griefing.service"; -import { SolverGriefingController } from "./solver-griefing.controller"; -import { MetricsService } from "../metrics/metrics.service"; - -@Module({ - imports: [forwardRef(() => IntentsModule)], - controllers: [SolversController, SolverGriefingController], - providers: [ - // Select the persistence adapter based on SOLVERS_PERSISTENCE env var. - { - provide: SOLVERS_REPOSITORY, - inject: [PrismaService], - useFactory: (prisma: PrismaService) => { - const adapter = process.env.SOLVERS_PERSISTENCE ?? "memory"; - if (adapter === "prisma") { - return new PrismaSolversRepository(prisma); - } - return new InMemorySolversRepository(); - }, - }, - SolversService, - // Anti-griefing enforcement engine (issue #453). - SolverGriefingService, - ], - exports: [SolversService, SolverGriefingService], -}) -export class SolversModule implements OnModuleInit { - constructor( - private readonly griefingService: SolverGriefingService, - private readonly metricsService: MetricsService, - ) {} - - /** - * Wire MetricsService into SolverGriefingService after both providers are - * initialised. MetricsModule is @Global() so it is always available; we - * inject it here rather than in SolverGriefingService's constructor to avoid - * a circular-module dependency (Metrics → Solvers → Metrics). - */ - onModuleInit(): void { - this.griefingService.setMetrics(this.metricsService); - } -} -import { Module, forwardRef } from "@nestjs/common"; -import { SolversController } from "./solvers.controller"; -import { SolversService } from "./solvers.service"; -import { ReputationService } from "./reputation.service"; -import { SOLVERS_REPOSITORY } from "./solvers.repository"; -import { InMemorySolversRepository } from "./in-memory-solvers.repository"; -import { PrismaSolversRepository } from "./prisma-solvers.repository"; -import { PrismaService } from "../prisma/prisma.service"; -import { IntentsModule } from "../intents/intents.module"; -import { SolverCredentialsModule } from "../auth/solver-credentials/solver-credentials.module"; -import { ConfigModule } from "@nestjs/config"; - -@Module({ - imports: [forwardRef(() => IntentsModule), SolverCredentialsModule, ConfigModule], - controllers: [SolversController], - providers: [ - // Select the persistence adapter based on SOLVERS_PERSISTENCE env var. - { - provide: SOLVERS_REPOSITORY, - inject: [PrismaService], - useFactory: (prisma: PrismaService) => { - const adapter = process.env.SOLVERS_PERSISTENCE ?? "memory"; - if (adapter === "prisma") { - return new PrismaSolversRepository(prisma); - } - return new InMemorySolversRepository(); - }, - }, - SolversService, - ReputationService, - ], - exports: [SolversService, ReputationService], -}) -export class SolversModule {} +import { Module, OnModuleInit, forwardRef } from "@nestjs/common"; +import { SolversController } from "./solvers.controller"; +import { SolverGriefingController } from "./solver-griefing.controller"; +import { SolversService } from "./solvers.service"; +import { ReputationService } from "./reputation.service"; +import { SolverGriefingService } from "./solver-griefing.service"; +import { SOLVERS_REPOSITORY } from "./solvers.repository"; +import { InMemorySolversRepository } from "./in-memory-solvers.repository"; +import { PrismaSolversRepository } from "./prisma-solvers.repository"; +import { PrismaService } from "../prisma/prisma.service"; +import { IntentsModule } from "../intents/intents.module"; +import { SolverCredentialsModule } from "../auth/solver-credentials/solver-credentials.module"; +import { ConfigModule } from "@nestjs/config"; +import { MetricsService } from "../metrics/metrics.service"; + +@Module({ + imports: [forwardRef(() => IntentsModule), SolverCredentialsModule, ConfigModule], + controllers: [SolversController, SolverGriefingController], + providers: [ + // Select the persistence adapter based on SOLVERS_PERSISTENCE env var. + { + provide: SOLVERS_REPOSITORY, + inject: [PrismaService], + useFactory: (prisma: PrismaService) => { + const adapter = process.env.SOLVERS_PERSISTENCE ?? "memory"; + if (adapter === "prisma") { + return new PrismaSolversRepository(prisma); + } + return new InMemorySolversRepository(); + }, + }, + SolversService, + ReputationService, + // Anti-griefing enforcement engine (issue #453). + SolverGriefingService, + ], + exports: [SolversService, ReputationService, SolverGriefingService], +}) +export class SolversModule implements OnModuleInit { + constructor( + private readonly griefingService: SolverGriefingService, + private readonly metricsService: MetricsService, + ) {} + + /** + * Wire MetricsService into SolverGriefingService after both providers are + * initialised. MetricsModule is @Global() so it is always available; we + * inject it here rather than in SolverGriefingService's constructor to avoid + * a circular-module dependency (Metrics → Solvers → Metrics). + */ + onModuleInit(): void { + this.griefingService.setMetrics(this.metricsService); + } +} diff --git a/src/soroban/backfill.service.spec.ts b/src/soroban/backfill.service.spec.ts index 45673d4e..5e7184b6 100644 --- a/src/soroban/backfill.service.spec.ts +++ b/src/soroban/backfill.service.spec.ts @@ -17,7 +17,7 @@ import type { AppConfig } from "../config/configuration"; // ─── Helpers ───────────────────────────────────────────────────────────────── const CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"; -const SOLVER_STRKEY = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP"; +const SOLVER_STRKEY = "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T"; function fakeEvent(ledger: number, idx = 0): SorobanRpc.Api.EventResponse { return { diff --git a/src/soroban/channel-pool.service.ts b/src/soroban/channel-pool.service.ts index b1a7fb0e..e59903b7 100644 --- a/src/soroban/channel-pool.service.ts +++ b/src/soroban/channel-pool.service.ts @@ -124,26 +124,27 @@ export class ChannelPoolService implements OnModuleInit { this.metrics.channelLeaseWaitTime.observe(waitMs / 1000); const kp = this.keypairs.get(ch.publicKey)!; - const self = this; return { publicKey: ch.publicKey, keypair: kp, sequence, - release(success: boolean): void { - const target = self.channels.find((c) => c.publicKey === ch.publicKey); + // Arrow property, not a method: the handle must close over the class + // instance (`this`), which the object-literal method form would rebind. + release: (success: boolean): void => { + const target = this.channels.find((c) => c.publicKey === ch.publicKey); if (target) { target.leased = false; target.leaseExpiresAt = 0; if (!success) { // Sequence is suspect — force re-sync on next use target.cachedSequence = null; - self.metrics.channelBadSeqResyncs.inc(); + this.metrics.channelBadSeqResyncs.inc(); } } - self.updateUtilisationMetric(); + this.updateUtilisationMetric(); // Wake the next waiter in FIFO order - const next = self.waiters.shift(); + const next = this.waiters.shift(); if (next) next(); }, }; diff --git a/src/soroban/contracts/settlement.client.ts b/src/soroban/contracts/settlement.client.ts index e69de29b..9ea162c0 100644 --- a/src/soroban/contracts/settlement.client.ts +++ b/src/soroban/contracts/settlement.client.ts @@ -0,0 +1,292 @@ +/** + * Read-only client for the on-chain settlement contract. + * + * Uses `simulateTransaction` to call view functions (get_intent, get_state) + * without ever submitting a transaction. This is safe at any time — simulation + * never mutates ledger state. + * + * Used by ReconcilerService to read the canonical on-chain intent state and + * diff it against Postgres. + * + * @module soroban/contracts/settlement.client + */ + +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + Address, + BASE_FEE, + Contract, + Keypair, + Networks, + SorobanRpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import type { AppConfig } from "../../config/configuration"; +import type { Intent } from "../../intents/intents.types"; +import { ContractVersionService } from "../contract-version.service"; +import { InvokeContractResult, StellarTxService } from "../stellar-tx.service"; +import type { SettlementAbiVersion } from "./contract-versions"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type OnChainIntentState = + | "open" + | "accepted" + | "filled" + | "cancelled" + | "expired" + | "slashed" + | "unknown"; + +export interface OnChainIntent { + intentId: string; + user: string; + solver: string | null; + state: OnChainIntentState; + srcAmount: bigint; + minDstAmount: bigint; + fillAmount: bigint | null; + deadline: bigint; +} + +export interface SimulationResult { + ok: true; + value: T; + /** Raw simulation result for debugging. */ + rawReturnValue: xdr.ScVal; +} + +export type SimulationError = { + ok: false; + error: string; + /** "not_found" when the contract returns a None/null (intent doesn't exist on-chain). */ + reason: "not_found" | "simulation_error" | "decode_error" | "not_configured"; +}; + +const NETWORK_PASSPHRASE: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +/** + * Maps the raw contract state string/symbol to our canonical `OnChainIntentState`. + */ +function mapContractState(raw: unknown): OnChainIntentState { + const s = String(raw ?? "").toLowerCase(); + switch (s) { + case "open": return "open"; + case "accepted": return "accepted"; + case "filled": return "filled"; + case "cancelled": return "cancelled"; + case "expired": return "expired"; + case "slashed": return "slashed"; + default: return "unknown"; + } +} + +@Injectable() +export class SettlementClient { + private readonly logger = new Logger(SettlementClient.name); + private readonly server: SorobanRpc.Server; + private readonly contractId: string; + private readonly networkPassphrase: string; + + constructor(configService: ConfigService) { + const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true }); + this.server = new SorobanRpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), + }); + this.contractId = configService.get("stellar.settlementContractId", { infer: true }); + const network = configService.get("stellar.network", { infer: true }); + this.networkPassphrase = NETWORK_PASSPHRASE[network]; + } + + /** True when a settlement contract ID is configured. */ + get isConfigured(): boolean { + return this.contractId.length > 0; + } + + /** + * Read the intent record from the settlement contract for `intentId`. + * + * Uses a simulation against a throw-away ephemeral account so no real + * account with a sequence number is needed for read-only calls. + * + * Returns `SimulationError` with `reason: "not_found"` when the contract + * returns None (the intent does not exist on-chain). + */ + async getIntent( + intentId: string, + ): Promise | SimulationError> { + if (!this.isConfigured) { + return { ok: false, error: "SETTLEMENT_CONTRACT_ID not configured", reason: "not_configured" }; + } + + try { + const ephemeralKeypair = Keypair.random(); + const account = await this.server.getAccount(ephemeralKeypair.publicKey()).catch(() => { + // Build a synthetic account-like object with sequence 0 for simulation. + // simulateTransaction does not validate the account exists on ledger. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { + id: ephemeralKeypair.publicKey(), + sequence: "0", + accountId() { return ephemeralKeypair.publicKey(); }, + sequenceNumber() { return "0"; }, + incrementSequenceNumber() { return; }, + } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + const contract = new Contract(this.contractId); + const operation = contract.call( + "get_intent", + nativeToScVal(intentId, { type: "string" }), + ); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await this.server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulation)) { + const errStr = simulation.error; + if (errStr.includes("not_found") || errStr.includes("None")) { + return { ok: false, error: `Intent ${intentId} not found on-chain`, reason: "not_found" }; + } + return { ok: false, error: `Simulation error: ${errStr}`, reason: "simulation_error" }; + } + + const successSim = simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse; + if (!successSim.result?.retval) { + return { ok: false, error: "Simulation returned no retval", reason: "not_found" }; + } + + const retval = successSim.result.retval; + const decoded = this.decodeIntentRetval(retval, intentId); + return { ok: true, value: decoded, rawReturnValue: retval }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + this.logger.error(`[settlement-client] getIntent failed for ${intentId}: ${error}`); + return { ok: false, error, reason: "simulation_error" }; + } + } + + /** + * Batch-read intents from the contract with bounded concurrency. + * + * @param intentIds Intent IDs to read. + * @param concurrency Maximum number of parallel simulations. Defaults to 5. + */ + async getManyIntents( + intentIds: string[], + concurrency = 5, + ): Promise | SimulationError>> { + const results = new Map | SimulationError>(); + const queue = [...intentIds]; + + while (queue.length > 0) { + const batch = queue.splice(0, concurrency); + const settled = await Promise.allSettled( + batch.map((id) => this.getIntent(id)), + ); + for (let i = 0; i < batch.length; i++) { + const s = settled[i]; + results.set( + batch[i], + s.status === "fulfilled" + ? s.value + : { ok: false, error: String((s as PromiseRejectedResult).reason), reason: "simulation_error" }, + ); + } + } + + return results; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private decodeIntentRetval(retval: xdr.ScVal, intentId: string): OnChainIntent { + const native = scValToNative(retval) as Record; + + return { + intentId, + user: String(native["user"] ?? ""), + solver: native["solver"] ? String(native["solver"]) : null, + state: mapContractState(native["state"] ?? native["status"]), + srcAmount: BigInt(String(native["src_amount"] ?? native["srcAmount"] ?? 0)), + minDstAmount: BigInt(String(native["min_dst_amount"] ?? native["minDstAmount"] ?? 0)), + fillAmount: native["fill_amount"] != null + ? BigInt(String(native["fill_amount"] ?? native["fillAmount"] ?? 0)) + : null, + deadline: BigInt(String(native["deadline"] ?? 0)), + }; + } +} + +// ─── Version-aware write client (issue #402) ───────────────────────────────── + +/** Encodes settlement-contract calls for one ABI version. */ +export interface SettlementCodec { + createIntent(intent: Intent): { method: string; args: xdr.ScVal[] }; +} + +/** + * One codec per supported settlement ABI (issue #402). A new ABI gets a new + * entry here plus hash mappings in SUPPORTED_CONTRACT_VERSIONS — existing + * codecs are never edited in place, so a rollback keeps working. + */ +export const SETTLEMENT_CODECS: Record = { + "settlement-v1": { + createIntent: (intent) => ({ + method: "create_intent", + args: [ + nativeToScVal(intent.intentId, { type: "string" }), + new Address(intent.user).toScVal(), + nativeToScVal(intent.srcChain, { type: "symbol" }), + nativeToScVal(intent.srcToken.address, { type: "string" }), + nativeToScVal(BigInt(intent.srcAmount), { type: "i128" }), + new Address(intent.dstToken.contract).toScVal(), + nativeToScVal(BigInt(intent.minDstAmount), { type: "i128" }), + nativeToScVal(intent.deadline, { type: "u64" }), + ], + }), + }, +}; + +/** + * Version-aware client for the settlement contract. + * + * Every write first asks ContractVersionService for the deployed ABI (which + * re-reads the WASM hash when the cached one is older than 60 s) and encodes + * with that version's codec. Unknown or unreadable versions throw a 503 + * before anything is built or submitted. + */ +@Injectable() +export class SettlementContractClient { + constructor( + private readonly stellarTx: StellarTxService, + private readonly versions: ContractVersionService, + private readonly configService: ConfigService, + ) {} + + get contractId(): string { + return this.configService.get("stellar.settlementContractId", { infer: true }); + } + + /** Register `intent` with the settlement contract via `create_intent`. */ + async createIntent(intent: Intent): Promise { + const { abiVersion } = await this.versions.assertWritable("settlement"); + const { method, args } = SETTLEMENT_CODECS[abiVersion].createIntent(intent); + return this.stellarTx.invokeContract({ contractId: this.contractId, method, args }); + } +} diff --git a/src/soroban/events/__fixtures__/events.json b/src/soroban/events/__fixtures__/events.json index caaf4324..bf2777b5 100644 --- a/src/soroban/events/__fixtures__/events.json +++ b/src/soroban/events/__fixtures__/events.json @@ -11,7 +11,7 @@ "_nativeTopics": [ "intent_registered", "550e8400-e29b-41d4-a716-446655440000", - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7" ], "_nativeValue": { "src_chain": "stellar", @@ -23,7 +23,7 @@ }, "expectedPayload": { "intentId": "550e8400-e29b-41d4-a716-446655440000", - "user": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", + "user": "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7", "srcChain": "stellar", "srcToken": "native", "srcAmount": "1000000000", @@ -43,14 +43,14 @@ "_nativeTopics": [ "intent_accepted", "550e8400-e29b-41d4-a716-446655440000", - "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP" + "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T" ], "_nativeValue": { "fill_deadline": "1704067320" }, "expectedPayload": { "intentId": "550e8400-e29b-41d4-a716-446655440000", - "solver": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + "solver": "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", "fillDeadline": "1704067320" } }, @@ -65,7 +65,7 @@ "_nativeTopics": [ "intent_filled", "550e8400-e29b-41d4-a716-446655440000", - "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP" + "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T" ], "_nativeValue": { "fill_amount": "995000000", @@ -74,7 +74,7 @@ }, "expectedPayload": { "intentId": "550e8400-e29b-41d4-a716-446655440000", - "solver": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + "solver": "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", "fillAmount": "995000000", "feeAmount": "5000000", "txHash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" @@ -91,12 +91,12 @@ "_nativeTopics": [ "intent_cancelled", "660e8400-e29b-41d4-a716-446655440001", - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7" ], "_nativeValue": {}, "expectedPayload": { "intentId": "660e8400-e29b-41d4-a716-446655440001", - "cancelledBy": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + "cancelledBy": "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7" } }, "solver_slashed": { @@ -104,12 +104,12 @@ "ledger": 100004, "ledgerClosedAt": "2024-01-01T00:00:20Z", "txHash": "babe0000babe0000babe0000babe0000babe0000babe0000babe0000babe0000", - "contractId": "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFQXK5", + "contractId": "CC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53WQD5", "type": "contract", "pagingToken": "100004-0", "_nativeTopics": [ "solver_slashed", - "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", "550e8400-e29b-41d4-a716-446655440000" ], "_nativeValue": { @@ -117,7 +117,7 @@ "reason": "missed_fill_deadline" }, "expectedPayload": { - "solver": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + "solver": "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", "intentId": "550e8400-e29b-41d4-a716-446655440000", "slashAmount": "500000000", "reason": "missed_fill_deadline" @@ -128,19 +128,19 @@ "ledger": 100005, "ledgerClosedAt": "2024-01-01T00:00:25Z", "txHash": "feed0000feed0000feed0000feed0000feed0000feed0000feed0000feed0000", - "contractId": "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFQXK5", + "contractId": "CC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53WQD5", "type": "contract", "pagingToken": "100005-0", "_nativeTopics": [ "bond_updated", - "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP" + "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T" ], "_nativeValue": { "new_bond_amount": "1500000000", "delta": "500000000" }, "expectedPayload": { - "solver": "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + "solver": "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", "newBondAmount": "1500000000", "delta": "500000000" } diff --git a/src/soroban/events/decoders.spec.ts b/src/soroban/events/decoders.spec.ts index dfa21c22..afd14aef 100644 --- a/src/soroban/events/decoders.spec.ts +++ b/src/soroban/events/decoders.spec.ts @@ -17,8 +17,8 @@ import { decodeEvent, KNOWN_TOPICS } from "./decoders"; // ─── Helpers ───────────────────────────────────────────────────────────────── -const KNOWN_STRKEY = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; -const SOLVER_STRKEY = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP"; +const KNOWN_STRKEY = "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7"; +const SOLVER_STRKEY = "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T"; const CONTRACT_STRKEY = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"; const INTENT_ID = "550e8400-e29b-41d4-a716-446655440000"; diff --git a/src/soroban/events/decoders.ts b/src/soroban/events/decoders.ts index 4da271a0..7a2a7faf 100644 --- a/src/soroban/events/decoders.ts +++ b/src/soroban/events/decoders.ts @@ -14,7 +14,7 @@ * @module soroban/events/decoders */ -import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"; +import { Address, StrKey, scValToNative, xdr } from "@stellar/stellar-sdk"; import type { SorobanRpc } from "@stellar/stellar-sdk"; import { BondUpdatedPayloadV1, @@ -68,6 +68,19 @@ function addressToStrkey(scVal: xdr.ScVal): string { return Address.fromScVal(scVal).toString(); } +/** + * Extract the dst token strkey from the event body. The contract publishes + * the address inline, which `scValToNative` already turns into a "G…"/"C…" + * strkey string (matching the golden fixtures); older ledgers encoded it as a + * base64 XDR ScVal, so that shape is still accepted. + */ +function dstTokenToStrkey(raw: unknown): string { + if (typeof raw === "string" && (StrKey.isValidEd25519PublicKey(raw) || StrKey.isValidContract(raw))) { + return raw; + } + return addressToStrkey(xdr.ScVal.fromXDR(Buffer.from(String(raw ?? ""), "base64"))); +} + /** * Convert a Bytes ScVal to a lowercase hex string. * Throws if the value is not bytes. @@ -100,9 +113,7 @@ function decodeIntentRegistered( srcChain: body?.src_chain as string, srcToken: body?.src_token as string, srcAmount: body?.src_amount as bigint, - dstToken: addressToStrkey( - xdr.ScVal.fromXDR(Buffer.from(String(body?.dst_token ?? ""), "base64")), - ), + dstToken: dstTokenToStrkey(body?.dst_token), minDstAmount: body?.min_dst_amount as bigint, deadline: body?.deadline as bigint, }; diff --git a/src/soroban/events/registry.spec.ts b/src/soroban/events/registry.spec.ts index 4ea50179..ab6a4ba8 100644 --- a/src/soroban/events/registry.spec.ts +++ b/src/soroban/events/registry.spec.ts @@ -12,7 +12,7 @@ import { } from "@stellar/stellar-sdk"; import { EventDecoderRegistry, type DeadLetterEntry } from "./registry"; -const SOLVER_STRKEY = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP"; +const SOLVER_STRKEY = "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T"; const INTENT_ID = "550e8400-e29b-41d4-a716-446655440000"; function str(s: string): xdr.ScVal { return nativeToScVal(s, { type: "string" }); } diff --git a/src/soroban/fill-verifier.service.spec.ts b/src/soroban/fill-verifier.service.spec.ts index e69de29b..0633c2e3 100644 --- a/src/soroban/fill-verifier.service.spec.ts +++ b/src/soroban/fill-verifier.service.spec.ts @@ -0,0 +1,76 @@ +import { Asset } from "@stellar/stellar-sdk"; +import { FillVerifierService } from "./fill-verifier.service"; +import { HttpEgressService } from "../common/http-egress"; +import { NETWORK_PASSPHRASES } from "../config/configuration"; +import { Intent } from "../intents/intents.types"; + +describe("FillVerifierService", () => { + const intent = { + intentId: "intent-123", + user: "GDESTINATION", + dstToken: { contract: Asset.native().contractId(NETWORK_PASSPHRASES.testnet), decimals: 7 }, + minDstAmount: "12000000", + } as Intent; + const config = { + get: (key: string) => key === "stellar.network" ? "testnet" : "https://horizon.test", + } as never; + const service = new FillVerifierService(config); + + // Horizon traffic goes through HttpEgressService (the SSRF-guarded transport + // enforced by lint), so the egress layer is spied and fed queued Response + // fixtures instead of mocking global fetch. + const queued: Response[] = []; + const fetchSpy = jest.spyOn(HttpEgressService.prototype, "fetch"); + fetchSpy.mockImplementation(async () => { + const next = queued.shift(); + if (!next) throw new Error("no queued Horizon response"); + return { statusCode: next.status, headers: {}, body: await next.text(), bodyBytes: 0, finalUrl: "", ipUsed: "" }; + }); + + afterEach(() => { queued.length = 0; }); + afterAll(() => { fetchSpy.mockRestore(); }); + + it("uses the delivered amount for path payments before verifying the minimum", async () => { + queued.push( + new Response(JSON.stringify({ + successful: true, + memo_type: "text", + memo: intent.intentId, + _links: { operations: { href: "ignored" } }, + }), { status: 200 }), + new Response(JSON.stringify({ _embedded: { records: [{ + type: "path_payment_strict_send", + to: intent.user, + asset_type: "native", + destination_amount: "1.3", + amount: "2.0", + }] } }), { status: 200 }), + ); + + await expect(service.verify("a".repeat(64), intent)).resolves.toEqual({ + status: "verified", + deliveredAmount: "13000000", + operation: "path_payment_strict_send", + }); + }); + + it("keeps a transaction not yet indexed by Horizon retryable", async () => { + queued.push(new Response("{}", { status: 404 })); + await expect(service.verify("b".repeat(64), intent)).resolves.toEqual({ + status: "pending", + reason: "not_indexed", + }); + }); + + it("rejects a successful transaction without the intent binding memo", async () => { + queued.push(new Response(JSON.stringify({ + successful: true, + memo_type: "text", + memo: "another-intent", + }), { status: 200 })); + await expect(service.verify("c".repeat(64), intent)).resolves.toEqual({ + status: "rejected", + reason: "intent_memo_mismatch", + }); + }); +}); diff --git a/src/soroban/fill-verifier.service.ts b/src/soroban/fill-verifier.service.ts index 716469c3..8e470e35 100644 --- a/src/soroban/fill-verifier.service.ts +++ b/src/soroban/fill-verifier.service.ts @@ -1,9 +1,17 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger, Optional } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { Asset } from "@stellar/stellar-sdk"; +import { Asset, SorobanRpc, StrKey, scValToNative, xdr } from "@stellar/stellar-sdk"; import { EgressPurpose, HttpEgressService } from "../common/http-egress"; import { AppConfig, NETWORK_PASSPHRASES } from "../config/configuration"; import { Intent } from "../intents/intents.types"; +import { SorobanService } from "./soroban.service"; + +/** Approximate Stellar ledger close interval, used to size the event lookback. */ +const LEDGER_SECONDS = 5; +/** Longest per-chain fill window (CHAIN_FILL_WINDOW_DEFAULTS.ethereum) — a fill can't predate acceptance by more. */ +const MAX_FILL_WINDOW_SECONDS = 1800; +const EVENTS_PAGE_LIMIT = 200; +const MAX_EVENT_PAGES = 10; /** A structured independent verdict for a submitted Stellar fill transaction. */ export type FillVerificationVerdict = @@ -18,13 +26,45 @@ type HorizonTransaction = { _links?: { operations?: { href?: string } }; }; -/** Independently checks Horizon's indexed Stellar transaction and payment operations. */ +/** A fill observed on-chain, judged on chain time (ledger close). */ +export interface LandedFill { + txHash: string; + ledger: number; + /** Ledger close time, unix seconds — chain time, not server time. */ + closedAt: number; +} + +/** + * Independent verification of solver fills. + * + * Two views over the same question ("did this fill really land?"): + * + * - {@link verify} checks Horizon's indexed classic payments against a + * persisted intent (memo, destination, asset contract, delivered amount). + * - {@link findLandedFill} / {@link verifyFillProof} (issue #397) answer from + * chain data alone for the slashing saga: settlement-contract events and + * transaction meta, timed on ledger close rather than server time. + * + * The chain-data methods throw on RPC failure: the caller must treat + * "couldn't check" as "don't slash yet", never as "no fill". + */ @Injectable() export class FillVerifierService { + private readonly logger = new Logger(FillVerifierService.name); private readonly egress: HttpEgressService; private readonly horizonBase: string; + private readonly settlementContractId: string; - constructor(private readonly config: ConfigService) { + constructor( + private readonly config: ConfigService, + /** + * Soroban RPC handle for the chain-data methods. Injected `@Optional()` + * so Horizon-only graphs (and the unit harnesses that construct this + * service directly) keep working; the chain methods fail loudly rather + * than silently answering "no fill" when it is absent. + */ + @Optional() private readonly sorobanService?: SorobanService, + ) { const horizonUrl = config.get("stellar.horizonUrl", { infer: true }); this.horizonBase = horizonUrl.replace(/\/$/, ""); this.egress = new HttpEgressService({ @@ -34,6 +74,7 @@ export class FillVerifierService { allowlist: [new URL(horizonUrl).hostname], blockPrivateRanges: false, }); + this.settlementContractId = config.get("stellar.settlementContractId", { infer: true }); } /** @@ -98,6 +139,127 @@ export class FillVerifierService { return { status: "pending", reason: "horizon_unavailable" }; } } + + // ── Chain-data verification for the slashing saga (issue #397) ──────────── + + /** + * Scans the settlement contract's recent events for an `intent_filled` + * event for `intentId` that closed by `latestAcceptable`. + * + * Returns null when none is found — including when SETTLEMENT_CONTRACT_ID is + * unset, since there is then no on-chain fill path to verify against. + */ + async findLandedFill( + intentId: string, + fillDeadline: number, + latestAcceptable: number, + now: number = Math.floor(Date.now() / 1000), + ): Promise { + if (!this.settlementContractId) return null; + const soroban = this.requireSoroban(); + + const latest = await soroban.getLatestLedger(); + const lookbackSeconds = Math.max(0, now - fillDeadline) + MAX_FILL_WINDOW_SECONDS; + const startLedger = Math.max(1, latest.sequence - Math.ceil(lookbackSeconds / LEDGER_SECONDS)); + + let cursor: string | undefined; + for (let page = 0; page < MAX_EVENT_PAGES; page++) { + const response = await soroban.getEvents({ + ...(cursor ? { cursor } : { startLedger }), + filters: [{ type: "contract", contractIds: [this.settlementContractId] }], + limit: EVENTS_PAGE_LIMIT, + }); + + for (const event of response.events) { + if (!isIntentFilled(event.topic, intentId)) continue; + const closedAt = Math.floor(Date.parse(event.ledgerClosedAt) / 1000); + if (closedAt <= latestAcceptable) { + return { txHash: event.txHash, ledger: event.ledger, closedAt }; + } + this.logger.log( + `[fill-verifier] intent=${intentId} fill ${event.txHash} closed at ${closedAt}, ` + + `after the acceptable bound ${latestAcceptable} — does not cancel the slash`, + ); + } + + if (response.events.length < EVENTS_PAGE_LIMIT) break; + cursor = response.events[response.events.length - 1].pagingToken; + } + return null; + } + + /** + * Verifies a solver-supplied fill proof: `txHash` must be a successful + * transaction, closed by `latestAcceptable`, that emitted `intent_filled` + * for `intentId` (from the settlement contract, when configured). + */ + async verifyFillProof( + txHash: string, + intentId: string, + latestAcceptable: number, + ): Promise<{ valid: true; fill: LandedFill } | { valid: false; reason: string }> { + const soroban = this.requireSoroban(); + const tx = await soroban.getTransaction(txHash); + if (tx.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + return { valid: false, reason: `transaction status is ${tx.status}` }; + } + if (tx.createdAt > latestAcceptable) { + return { + valid: false, + reason: `fill closed at ${tx.createdAt}, after the acceptable bound ${latestAcceptable}`, + }; + } + if (!this.emitsIntentFilled(tx.resultMetaXdr, intentId)) { + return { valid: false, reason: "transaction did not emit intent_filled for this intent" }; + } + return { valid: true, fill: { txHash, ledger: tx.ledger, closedAt: tx.createdAt } }; + } + + /** The RPC handle, or a loud failure — absence must never read as "no fill". */ + private requireSoroban(): SorobanService { + if (!this.sorobanService) { + throw new Error( + "FillVerifierService: SorobanService is not wired; chain-data verification unavailable", + ); + } + return this.sorobanService; + } + + private emitsIntentFilled(meta: xdr.TransactionMeta, intentId: string): boolean { + let events: xdr.ContractEvent[] = []; + try { + events = meta.v3().sorobanMeta()?.events() ?? []; + } catch { + return false; + } + return events.some((event) => { + if (this.settlementContractId) { + const contractId = event.contractId(); + if (!contractId || !this.matchesSettlementContract(contractId)) return false; + } + try { + return isIntentFilled(event.body().v0().topics(), intentId); + } catch { + return false; + } + }); + } + + private matchesSettlementContract(contractId: Buffer): boolean { + return StrKey.encodeContract(contractId) === this.settlementContractId; + } +} + +/** Topic layout shared with EventIngestionService: [event name, intentId, ...]. */ +function isIntentFilled(topic: xdr.ScVal[], intentId: string): boolean { + const decoded = topic.slice(0, 2).map((scVal) => { + try { + return scValToNative(scVal); + } catch { + return undefined; + } + }); + return decoded[0] === "intent_filled" && decoded[1] === intentId; } function decimalToBaseUnits(value: string, decimals: number): string { diff --git a/src/soroban/outbox-relay.service.spec.ts b/src/soroban/outbox-relay.service.spec.ts index 9f3f874d..f61d1554 100644 --- a/src/soroban/outbox-relay.service.spec.ts +++ b/src/soroban/outbox-relay.service.spec.ts @@ -145,9 +145,9 @@ describe("OutboxRelayService (#396)", () => { { get: (k: string) => (k === "onchainIntentsEnabled" ? true : k === "stellar.settlementContractId" ? CONTRACT_ID : undefined) } as unknown as ConfigService, {} as StellarTxService, { intentAuditLog: { create: jest.fn().mockResolvedValue({}) } } as unknown as PrismaService, + { snapshotForChain: jest.fn().mockReturnValue({ version: 0, deadlineSeconds: 1800, fillWindowSeconds: 600 }) } as unknown as ProtocolParamsService, undefined, undefined, - { snapshotForChain: jest.fn().mockReturnValue({ version: 0, deadlineSeconds: 1800, fillWindowSeconds: 600 }) } as unknown as ProtocolParamsService, undefined, new InMemoryIntentsUnitOfWork(repo, outbox), ); diff --git a/src/soroban/reconciler.service.spec.ts b/src/soroban/reconciler.service.spec.ts index 608bbb72..26619219 100644 --- a/src/soroban/reconciler.service.spec.ts +++ b/src/soroban/reconciler.service.spec.ts @@ -13,19 +13,17 @@ import { ConfigService } from "@nestjs/config"; import type { Intent } from "../intents/intents.types"; import type { AppConfig } from "../config/configuration"; -import type { AppConfig } from "../config/configuration"; - // ─── Helpers ───────────────────────────────────────────────────────────────── const BASE_INTENT: Intent = { intentId: "550e8400-e29b-41d4-a716-446655440000", - user: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", + user: "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7", srcChain: "stellar", srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" as const }, srcAmount: "1000000000", dstToken: { contract: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", symbol: "USDC", decimals: 6 }, minDstAmount: "990000000", - solver: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP", + solver: "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T", state: "accepted", createdAt: Math.floor(Date.now() / 1000) - 1000, deadline: Math.floor(Date.now() / 1000) + 3600, @@ -157,7 +155,7 @@ describe("ReconcilerService", () => { describe("solver_mismatch divergence", () => { it("detects solver mismatch and repairs via acceptIfOpen", async () => { - const differentSolver = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + const differentSolver = "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7"; const chainResult = new Map([ [BASE_INTENT.intentId, { ok: true as const, @@ -165,7 +163,7 @@ describe("ReconcilerService", () => { }], ]); - const openIntent: Intent = { ...BASE_INTENT, state: "open", solver: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKW7MW8X2ONKGZGK6XOMP" }; + const openIntent: Intent = { ...BASE_INTENT, state: "open", solver: "GCZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFMVSWKZLFF6T" }; const configService = { get: jest.fn(() => false) } as unknown as ConfigService; const intentsService = { getByState: jest.fn((s: string) => Promise.resolve(s === "open" ? [openIntent] : [])), diff --git a/src/soroban/reconciler.service.ts b/src/soroban/reconciler.service.ts index f81ca410..942ded71 100644 --- a/src/soroban/reconciler.service.ts +++ b/src/soroban/reconciler.service.ts @@ -242,10 +242,8 @@ export class ReconcilerService { ): DivergenceClass | null { if (!local) return "missing_locally"; - if (local.state !== onChain.state && onChain.state !== "unknown") { - return "state_mismatch"; - } - + // Solver (identity) divergence outranks a lifecycle lag: repairing the + // state first would silently adopt the chain solver. if ( onChain.solver && local.solver && @@ -254,6 +252,10 @@ export class ReconcilerService { return "solver_mismatch"; } + if (local.state !== onChain.state && onChain.state !== "unknown") { + return "state_mismatch"; + } + if ( onChain.fillAmount !== null && local.fillAmount !== null && diff --git a/src/soroban/redaction.ts b/src/soroban/redaction.ts index e69de29b..769b9424 100644 --- a/src/soroban/redaction.ts +++ b/src/soroban/redaction.ts @@ -0,0 +1,54 @@ +const SENSITIVE_KEY_PATTERNS = [ + // Stellar secret seeds (S... strkeys, 56 chars) + /S[A-Z2-7]{55}/g, + // Generic key patterns in JSON/logs + /secretKey\s*[:=]\s*["']?\S+/gi, + /privateKey\s*[:=]\s*["']?\S+/gi, + /apiKey\s*[:=]\s*["']?\S+/gi, + /accessToken\s*[:=]\s*["']?\S+/gi, + /refreshToken\s*[:=]\s*["']?\S+/gi, + /jwt\s*[:=]\s*["']?\S+/gi, + /signingKey\s*[:=]\s*["']?\S+/gi, + /webhookSecret\s*[:=]\s*["']?\S+/gi, + /channelKey\s*[:=]\s*["']?\S+/gi, + /killswitchOperatorToken\s*[:=]\s*["']?\S+/gi, + /adminApiKeys\s*[:=]\s*["']?\S+/gi, + /sentryDsn\s*[:=]\s*["']?\S+/gi, + /vaultToken\s*[:=]\s*["']?\S+/gi, + // AWS secret access key + /AKIA[0-9A-Z]{16}/g, + // Generic password/secret in URL + /:\/\/[^:/\s]+:([^@/\s]{8,})@/gi, + // Database connection strings with passwords + /postgresql:\/\/[^:]+:([^@]+)@/gi, + /mysql:\/\/[^:]+:([^@]+)@/gi, + // Bearer tokens + /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, +]; + +/** + * Scan a serialized error/log payload for raw Stellar secret-key material. + * Returns the first few suspicious matches so tests can assert that logs and + * thrown errors never expose the hot-wallet seed or similar credentials. + */ +export function findSensitiveKeyMaterial(value: unknown): string[] { + const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); + const hits = new Set(); + + for (const pattern of SENSITIVE_KEY_PATTERNS) { + const matches = text.match(pattern); + if (!matches) continue; + for (const match of matches) { + hits.add(match); + } + } + + return [...hits].slice(0, 10); +} + +export function assertNoSensitiveKeyMaterial(value: unknown, context = "serialized payload"): void { + const leaked = findSensitiveKeyMaterial(value); + if (leaked.length > 0) { + throw new Error(`${context} contains sensitive key material: ${leaked.join(", ")}`); + } +} diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index e69de29b..d13afd70 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -0,0 +1,187 @@ +import { inspect } from "node:util"; +import { Account, FeeBumpTransaction, Keypair, Networks, Operation, Transaction, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { SignerService } from "./signer.service"; +import { SorobanService } from "./soroban.service"; +import { ISigner } from "./signers/signer.interface"; +import { findSensitiveKeyMaterial } from "./redaction"; + +/** + * Build a mock ISigner backed by an optional keypair. + * + * SignerService was refactored (issue #400) to delegate to an ISigner backend, + * so the tests construct it with a mock backend rather than a ConfigService. + */ +function fakeSigner(keypair?: Keypair, network: AppConfig["stellar"]["network"] = "testnet") { + const passphrase = + network === "mainnet" ? Networks.PUBLIC : network === "futurenet" ? Networks.FUTURENET : Networks.TESTNET; + return { + // Mirrors LocalKeypairSigner: an unconfigured backend has no public key and + // throws a clear, secret-free error when one is requested. + publicKey: () => { + if (!keypair) throw new Error("Signer is not configured: no signing key is available"); + return keypair.publicKey(); + }, + networkPassphrase: () => passphrase, + signTransaction: async (tx: T): Promise => { + if (keypair) tx.sign(keypair); + return tx; + }, + signAuthEntry: async (entry: xdr.SorobanAuthorizationEntry): Promise => entry, + } as unknown as ISigner; +} + +function fakeSorobanService(startingSequence = "100") { + return { + getAccount: jest.fn().mockImplementation(async (publicKey: string) => new Account(publicKey, startingSequence)), + } as unknown as jest.Mocked; +} + +describe("SignerService", () => { + it("reports unconfigured when no secret is set", () => { + const service = new SignerService(fakeSigner(), fakeSorobanService()); + expect(service.isConfigured()).toBe(false); + }); + + it("throws a clear, secret-free error when signing without a configured key", () => { + const service = new SignerService(fakeSigner(), fakeSorobanService()); + expect(() => service.getPublicKey()).toThrow(/not configured|signing key/i); + // The message must stay secret-free (no strkey / seed shape). + let message = ""; + try { + service.getPublicKey(); + } catch (err) { + message = (err as Error).message; + } + expect(message).not.toMatch(/^S[A-Z2-7]{55}$/); + }); + + it("derives the public key from the configured secret", () => { + const keypair = Keypair.random(); + const service = new SignerService(fakeSigner(keypair), fakeSorobanService()); + + expect(service.isConfigured()).toBe(true); + expect(service.getPublicKey()).toBe(keypair.publicKey()); + }); + + it("maps network config to the right passphrase", () => { + const soroban = fakeSorobanService(); + expect(new SignerService(fakeSigner(undefined, "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); + expect(new SignerService(fakeSigner(undefined, "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); + expect(new SignerService(fakeSigner(undefined, "mainnet"), soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); + }); + + it("signs a transaction with the configured key", async () => { + const keypair = Keypair.random(); + const service = new SignerService(fakeSigner(keypair), fakeSorobanService()); + + const account = new Account(keypair.publicKey(), "1"); + const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.bumpSequence({ bumpTo: "2" })) + .setTimeout(30) + .build(); + + expect(tx.signatures).toHaveLength(0); + const signed = await service.sign(tx); + expect(signed.signatures).toHaveLength(1); + }); + + it("never includes the raw secret in string/JSON/inspect representations", () => { + const keypair = Keypair.random(); + const service = new SignerService(fakeSigner(keypair), fakeSorobanService()); + + const secret = keypair.secret(); + expect(String(service)).not.toContain(secret); + expect(JSON.stringify(service)).not.toContain(secret); + expect(inspect(service)).not.toContain(secret); + expect(findSensitiveKeyMaterial(service)).toEqual([]); + }); + + it("exposes no raw Stellar secret in serialized error payloads", () => { + const keypair = Keypair.random(); + const secret = keypair.secret(); + const payload = { + error: "transaction simulation failed", + signer: { secretKey: secret, publicKey: keypair.publicKey() }, + }; + + expect(findSensitiveKeyMaterial(payload)).toContain(secret); + expect(findSensitiveKeyMaterial({ error: "ok" })).toEqual([]); + }); + + describe("withNextSequence", () => { + it("fetches the starting sequence once and increments it locally", async () => { + const keypair = Keypair.random(); + const soroban = fakeSorobanService("100"); + const service = new SignerService(fakeSigner(keypair), soroban); + + const first = await service.withNextSequence(async (sequence) => sequence); + const second = await service.withNextSequence(async (sequence) => sequence); + const third = await service.withNextSequence(async (sequence) => sequence); + + expect([first, second, third]).toEqual(["101", "102", "103"]); + expect(soroban.getAccount).toHaveBeenCalledTimes(1); + }); + + it("hands out a distinct, gap-free sequence to every concurrent caller", async () => { + const keypair = Keypair.random(); + const soroban = fakeSorobanService("0"); + const service = new SignerService(fakeSigner(keypair), soroban); + + const results = await Promise.all( + Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)), + ); + + const numeric = results.map(Number).sort((a, b) => a - b); + expect(new Set(numeric).size).toBe(20); // no two callers got the same sequence + expect(numeric).toEqual(Array.from({ length: 20 }, (_, i) => i + 1)); // 1..20, no gaps + }); + + it("runs callers strictly one at a time, in call order", async () => { + const keypair = Keypair.random(); + const service = new SignerService(fakeSigner(keypair), fakeSorobanService("0")); + const order: number[] = []; + + const slow = service.withNextSequence(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + order.push(1); + }); + const fast = service.withNextSequence(async () => { + order.push(2); + }); + + await Promise.all([slow, fast]); + expect(order).toEqual([1, 2]); // fast waited for slow despite finishing faster on its own + }); + + it("drops the cached sequence after a failure so the next call re-syncs from the network", async () => { + const keypair = Keypair.random(); + const soroban = fakeSorobanService("100"); + const service = new SignerService(fakeSigner(keypair), soroban); + + await expect( + service.withNextSequence(async () => { + throw new Error("submission failed"); + }), + ).rejects.toThrow("submission failed"); + + const next = await service.withNextSequence(async (sequence) => sequence); + expect(next).toBe("101"); + expect(soroban.getAccount).toHaveBeenCalledTimes(2); // re-fetched after the failure + }); + + it("does not let a failed caller block callers queued behind it", async () => { + const keypair = Keypair.random(); + const service = new SignerService(fakeSigner(keypair), fakeSorobanService("0")); + + const failing = service.withNextSequence(async () => { + throw new Error("boom"); + }); + const following = service.withNextSequence(async (sequence) => sequence); + + await expect(failing).rejects.toThrow("boom"); + // cache was dropped after the failure, so this re-syncs from the network (still "0") and gets "1" + await expect(following).resolves.toBe("1"); + }); + }); +}); diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts index fe99f42b..31343bd5 100644 --- a/src/soroban/signer.service.ts +++ b/src/soroban/signer.service.ts @@ -17,7 +17,7 @@ */ import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; -import { FeeBumpTransaction, Transaction, xdr } from "@stellar/stellar-sdk"; +import { FeeBumpTransaction, Keypair, Transaction, xdr } from "@stellar/stellar-sdk"; import { SorobanService } from "./soroban.service"; import { ISigner, SIGNER_TOKEN } from "./signers/signer.interface"; @@ -73,6 +73,19 @@ export class SignerService { } } + /** + * The signing keypair when the backend can expose it (fee-bump + * construction, issue #386). `undefined` for remote backends — key + * material never leaves them, so fee escalation is unavailable there. + */ + getFeeSourceKeypair(): Keypair | undefined { + try { + return this.backend.feeSourceKeypair?.(); + } catch { + return undefined; + } + } + // ── Sequence-number management ──────────────────────────────────────────── /** diff --git a/src/soroban/signers/local-keypair.signer.ts b/src/soroban/signers/local-keypair.signer.ts index 63549576..d8886999 100644 --- a/src/soroban/signers/local-keypair.signer.ts +++ b/src/soroban/signers/local-keypair.signer.ts @@ -138,6 +138,15 @@ export class LocalKeypairSigner implements ISigner, OnModuleInit { } } + /** + * The local signing keypair (issue #386): fee-bump construction re-signs + * the outer envelope with the same key that signed the inner transaction. + * Only the local backend can provide it — remote signers keep keys sealed. + */ + feeSourceKeypair(): Keypair { + return this.getKeypair(); + } + private getKeypair(): Keypair { if (!this.secretKey) { throw new Error("Soroban signer is not configured: set SOROBAN_SIGNING_KEY"); diff --git a/src/soroban/signers/signer.interface.ts b/src/soroban/signers/signer.interface.ts index 93eda156..b5800fb5 100644 --- a/src/soroban/signers/signer.interface.ts +++ b/src/soroban/signers/signer.interface.ts @@ -13,7 +13,7 @@ * ALLOW_LOCAL_SIGNER_IN_PROD=true is explicitly set. */ -import { FeeBumpTransaction, Transaction, xdr } from "@stellar/stellar-sdk"; +import { FeeBumpTransaction, Keypair, Transaction, xdr } from "@stellar/stellar-sdk"; export const SIGNER_TOKEN = Symbol("SIGNER"); @@ -48,4 +48,14 @@ export interface ISigner { * Sign a Soroban auth entry for contract-authorisation flows. */ signAuthEntry(entry: xdr.SorobanAuthorizationEntry): Promise; + + /** + * The signing keypair itself, when this backend holds one in process. + * + * Fee-bump construction (issue #386) needs the raw key to re-sign the + * outer envelope. Remote backends (Vault Transit) never expose key + * material and omit this method — callers must treat its absence as + * "escalation unavailable" and fall back to the terminal path. + */ + feeSourceKeypair?(): Keypair; } diff --git a/src/soroban/solver-bond.service.ts b/src/soroban/solver-bond.service.ts index 94420849..7f5e7ba7 100644 --- a/src/soroban/solver-bond.service.ts +++ b/src/soroban/solver-bond.service.ts @@ -61,21 +61,29 @@ export class SolverBondService { try { const account = await this.server.getAccount(address); const contract = new Contract(this.contractId); - const transaction = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: this.networkPassphrase, - }) - .addOperation(contract.call("get_bond", Address.fromString(address).toScVal())) - .addOperation(contract.call("is_active", Address.fromString(address).toScVal())) - .setTimeout(30) - .build(); - const simulation = await this.server.simulateTransaction(transaction); - if (SorobanRpc.Api.isSimulationError(simulation) || !simulation.results || simulation.results.length < 2) { - throw new Error(SorobanRpc.Api.isSimulationError(simulation) ? simulation.error : "missing simulation results"); - } + // Soroban allows one invoke-host-function operation per transaction, so + // each registry getter gets its own single-call envelope and the two + // values are read off the two simulation results. + const simulate = async (method: string): Promise => { + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, Address.fromString(address).toScVal())) + .setTimeout(30) + .build(); + const simulation = await this.server.simulateTransaction(transaction); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw new Error(simulation.error); + } + if (!simulation.result) { + throw new Error(`missing simulation result for ${method}`); + } + return scValToNative(simulation.result.retval); + }; - const bondNative = scValToNative(simulation.results[0].xdr); - const activeNative = scValToNative(simulation.results[1].xdr); + const bondNative = await simulate("get_bond"); + const activeNative = await simulate("is_active"); const bondAmount = this.parseBondAmount(bondNative); if (typeof activeNative !== "boolean") throw new Error("invalid active status returned by registry"); diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index e69de29b..34fcc6c4 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -0,0 +1,246 @@ +import { ConfigService } from "@nestjs/config"; +import { SolverRegistryService } from "./solver-registry.service"; +import { AppConfig } from "../config/configuration"; + +function makeConfigService( + overrides: Partial = {}, + appOverrides: Partial> = {}, +) { + const stellar: AppConfig["stellar"] = { + network: "testnet", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", + settlementContractId: "", + solverRegistryContractId: "", + signerSecretKey: "", + signingKey: "", + feePercentile: "p50", + maxFeeStroops: 1000000, + channelPoolSize: 8, + channelSecretKeys: "", + ...overrides, + }; + const config: AppConfig = { + nodeEnv: "test", + port: 4000, + databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", + databaseReplicaUrls: "", + maxReplicaLagMs: 5000, + archival: { + enabled: false, + bucketName: "", + endpoint: "", + region: "us-east-1", + accessKeyId: "", + secretAccessKey: "", + retentionDays: 30, + partitionPrefix: "date=", + maxRowsPerFile: 100000, + }, + stellar, + treasury: { address: "" }, + onchainIntentsEnabled: false, + legacyStellarSignatures: true, + evm: { + rpcAllowlist: [], + chains: { + ethereum: { chainId: 1, rpcUrl: "", escrowAddress: "" }, + base: { chainId: 8453, rpcUrl: "", escrowAddress: "" }, + polygon: { chainId: 137, rpcUrl: "", escrowAddress: "" }, + arbitrum: { chainId: 42161, rpcUrl: "", escrowAddress: "" }, + optimism: { chainId: 10, rpcUrl: "", escrowAddress: "" }, + avalanche: { chainId: 43114, rpcUrl: "", escrowAddress: "" }, + }, + depositVerificationEnabled: false, + rpcUrls: {}, + escrowAddresses: {}, + transferFeeToleranceBps: 0, + logLookbackBlocks: 10000, + }, + intentRetentionDays: 30, + intentRetentionSweepMs: 60000, + intentsVerifyIntervalMs: 60000, + safetySweepIntervalMs: 300000, + reputation: { + weights: { fillRate: 0.35, latency: 0.15, slashes: 0.25, quoteHonour: 0.15, volume: 0.1 }, + decayHalflifeSeconds: 2592000, + bayesAlpha: 4, + bayesBeta: 1, + volumeLambdaUsd: 100000, + historyWindowDays: 30, + }, + outbox: { + relayEnabled: false, + relayIntervalMs: 2000, + batchSize: 10, + maxAttempts: 8, + leaseSeconds: 120, + }, + slashing: { + challengeWindowSeconds: 600, + clockSkewToleranceSeconds: 30, + maxSubmitAttempts: 5, + }, + // Default to dry-run true for tests (safe default) + onchainDryRun: appOverrides.onchainDryRun ?? true, + corsOrigin: "*", + wsMaxConnections: 1000, + wsBackplane: "memory", + redisUrl: "redis://localhost:6379", + // Resource-exhaustion limits (issue #476) — test defaults + jsonMaxDepth: 10, + wsMaxFilterChains: 20, + wsMaxSubscriptions: 10, + dbQueryTimeoutMs: 5000, + dbBatchQueryTimeoutMs: 10000, + dbStatsQueryTimeoutMs: 15000, + // Emergency kill-switch (issue #477) — no operator token in unit tests, so + // the control plane stays disabled. + killswitch: { + operatorToken: "", + redisUrl: "", + pollMs: 2000, + }, + shadow: { + enabled: false, + sampleRate: 1, + queueMax: 256, + concurrency: 4, + sourceAccount: "", + }, + governance: { + paramsContractId: "", + paramsPollIntervalMs: 30_000, + }, + leaderElection: { + enabled: false, + heartbeatMs: 5000, + }, + processRole: "all", + jobs: { driver: "memory", shutdownTimeoutMs: 25000 }, + flags: { pubsub: "memory", refreshMs: 30000, overrides: "" }, + adminApiKeys: "", + guardianContractId: "", + canaryAddresses: [], + datasets: { + enabled: false, + anonymize: true, + salt: "", + saltRotationHours: 24, + saltRetentionWindows: 2, + publicBucket: "vortex-public-datasets", + storageKind: "local", + localDir: ".datasets", + }, + secrets: { + provider: "env", + refreshIntervalMs: 60000, + extra: "", + }, + ws: { + maxPayloadBytes: 16384, + maxConnectionsPerIp: 20, + trustProxyHops: 0, + rateLimitPerSec: 10, + rateLimitBurst: 20, + rateLimitMaxViolations: 5, + outboundQueueMax: 1000, + outboundBufferBytes: 1048576, + slowConsumerPolicy: "drop_oldest", + drainTimeoutMs: 25000, + }, + authJwtSecret: "", + rateLimitLocalPruneMs: 60000, + rateLimitRedisUrl: "", + credentialRevocationPubsub: "memory", + sse: { + heartbeatMs: 15000, + maxBufferBytes: 1048576, + }, + health: { + roles: ["api", "ws", "worker"], + checkIntervalMs: 5000, + readyFailureThreshold: 3, + readySuccessThreshold: 2, + eventLoopMaxLagMs: 1000, + rpcHealthUrls: ["https://soroban-testnet.stellar.org"], + }, + }; + return { + get: (key: string) => { + if (key === "onchainDryRun") return config.onchainDryRun; + const parts = key.split("."); + return (config as unknown as Record)[parts[0]] && parts[0] === "stellar" + ? (stellar as unknown as Record)[parts[1]] + : undefined; + }, + } as unknown as ConfigService; +} + +describe("SolverRegistryService", () => { + it("is not configured when the contract id and signing key are both empty (default)", () => { + const service = new SolverRegistryService(makeConfigService()); + expect(service.isConfigured).toBe(false); + }); + + it("is not configured when only the contract id is set", () => { + const service = new SolverRegistryService( + makeConfigService({ solverRegistryContractId: "CABCDEF" }), + ); + expect(service.isConfigured).toBe(false); + }); + + it("no-ops without contacting the network when unconfigured (dry-run=true)", async () => { + const service = new SolverRegistryService(makeConfigService()); + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.simulated).toBe(false); + // In dry-run mode, dryRun flag is true + expect(result.dryRun).toBe(true); + }); +}); + +// ── #260: dry-run flag behaviour ───────────────────────────────────────────── + +describe("SolverRegistryService — dry-run flag (#260)", () => { + it("returns dryRun:true without simulating when ONCHAIN_DRY_RUN=true", async () => { + const service = new SolverRegistryService( + makeConfigService( + { solverRegistryContractId: "CTEST123", signingKey: "S" + "A".repeat(55) }, + { onchainDryRun: true }, + ), + ); + + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.dryRun).toBe(true); + expect(result.detail).toMatch(/ONCHAIN_DRY_RUN=true/); + }); + + it("returns dryRun:false when ONCHAIN_DRY_RUN=false and service is not fully configured", async () => { + // With dryRun=false but contract not configured → falls through to no-op + const service = new SolverRegistryService( + makeConfigService({}, { onchainDryRun: false }), + ); + + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.dryRun).toBe(false); + expect(result.detail).toMatch(/not configured/i); + }); +}); diff --git a/src/soroban/solver-registry.service.ts b/src/soroban/solver-registry.service.ts index 50d513fb..0302b9e4 100644 --- a/src/soroban/solver-registry.service.ts +++ b/src/soroban/solver-registry.service.ts @@ -37,6 +37,13 @@ export interface SlashResult { submitted: boolean; /** true if we got far enough to run a (network, non-mutating) simulation. */ simulated: boolean; + /** + * true when this attempt failed and should count against the saga's retry + * budget (issue #397): simulation rejections, unconfigured registries, and + * transport errors retry with backoff instead of pretending a submission + * happened. A dry-run or simulated-only success is not a failure. + */ + failed: boolean; txHash?: string; detail: string; /** @@ -116,6 +123,7 @@ export class SolverRegistryService { return { submitted: false, simulated: false, + failed: false, dryRun: true, detail: "ONCHAIN_DRY_RUN=true — simulated log only, no transaction submitted", }; @@ -128,7 +136,7 @@ export class SolverRegistryService { this.logger.log( `[solver-registry] would slash solver=${params.solverAddress} intent=${params.intentId} reason="${params.reason}" (${detail})`, ); - return { submitted: false, simulated: false, dryRun: false, detail }; + return { submitted: false, simulated: false, failed: true, dryRun: false, detail }; } try { @@ -158,7 +166,7 @@ export class SolverRegistryService { this.logger.error( `[solver-registry] slash simulation errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, ); - return { submitted: false, simulated: true, dryRun: false, detail }; + return { submitted: false, simulated: true, failed: true, dryRun: false, detail }; } // TODO: Once issue #23 confirms the real contract interface, replace @@ -172,7 +180,7 @@ export class SolverRegistryService { this.logger.log( `[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`, ); - return { submitted: false, simulated: true, dryRun: false, detail }; + return { submitted: false, simulated: true, failed: false, dryRun: false, detail }; } catch (err) { // Issue #300 — the SDK may include serialized transaction/XDR details in // thrown errors; do not log the signing key or any raw secret here. @@ -180,7 +188,7 @@ export class SolverRegistryService { this.logger.error( `[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, ); - return { submitted: false, simulated: false, dryRun: false, detail }; + return { submitted: false, simulated: false, failed: true, dryRun: false, detail }; } } diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 3e17ee92..9ed88409 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -9,6 +9,7 @@ import { SolverRegistryService } from "./solver-registry.service"; import { SignerService } from "./signer.service"; import { StellarTxService } from "./stellar-tx.service"; import { TxConfirmationService } from "./tx-confirmation.service"; +import { FeeEscalationPolicy } from "./fee-escalation-policy"; import { SolverRegistryEventsService } from "./events/solver-registry-events.service"; import { SIGNER_TOKEN, signerFactory } from "./signers/signer.factory"; import { SolversModule } from "../solvers/solvers.module"; @@ -44,6 +45,9 @@ import { IntentsModule } from "../intents/intents.module"; // ── On-chain tx pipeline (issue #394) ───────────────────────────────── TxConfirmationService, + // Fee-escalation ladder used by TxConfirmationService's durable poller + // (issue #386): decides when a stuck envelope gets a fee bump. + FeeEscalationPolicy, StellarTxService, SolverRegistryService, diff --git a/src/soroban/soroban.service.spec.ts b/src/soroban/soroban.service.spec.ts index 8a38cbb1..f71604eb 100644 --- a/src/soroban/soroban.service.spec.ts +++ b/src/soroban/soroban.service.spec.ts @@ -137,7 +137,7 @@ describe("SorobanService", () => { // ------------------------------------------------------------------------- describe("getAccount", () => { - const PUBLIC_KEY = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + const PUBLIC_KEY = "GCQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DINBUGQ2DJX7"; it("delegates to server.getAccount with the given public key", async () => { const mockAccount = { id: PUBLIC_KEY, sequence: "12345" }; @@ -157,7 +157,7 @@ describe("SorobanService", () => { }); it("passes through different public keys correctly", async () => { - const anotherKey = "GBVVJJLE2VF7VKUQM7FXKCOQMHJZYJFXBSRH3DPHQHVJQCLJTPB65CG"; + const anotherKey = "GDB4HQ6DYPB4HQ6DYPB4HQ6DYPB4HQ6DYPB4HQ6DYPB4HQ6DYPB4H6AC"; mockGetAccount.mockResolvedValueOnce({ id: anotherKey }); await service.getAccount(anotherKey); diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index e69de29b..e1190320 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { FeeBumpTransaction, SorobanRpc, Transaction, xdr } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +@Injectable() +export class SorobanService { + private readonly server: SorobanRpc.Server; + private readonly rpcUrl: string; + + constructor(configService: ConfigService) { + const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true }); + this.rpcUrl = rpcUrl; + this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); + } + + getHealth() { + return this.server.getHealth(); + } + + getLatestLedger() { + return this.server.getLatestLedger(); + } + + getNetwork() { + return this.server.getNetwork(); + } + + getAccount(publicKey: string) { + return this.server.getAccount(publicKey); + } + + /** + * Fetch a ledger header by sequence number. + * + * Used by the event-ingestion loop to date the newest event it has seen: the + * `closeTime` here is what makes `vortex_event_ingestion_lag_seconds` a real + * measurement rather than a guess. + * + * @stellar/stellar-sdk 12 has no typed wrapper for the RPC `getLedgers` + * method, so the JSON-RPC call is issued directly. The result is returned in + * the `{ header: { closeTime } }` shape the ingestion loop reads. + */ + async getLedger(sequence: number): Promise<{ header?: { closeTime?: string } }> { + // `this.rpcUrl` is the operator-configured RPC endpoint from app config — + // the host is never attacker-controlled, so there is no SSRF surface here. + // eslint-disable-next-line no-restricted-syntax + const response = await fetch(this.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `get-ledgers-${sequence}`, + method: "getLedgers", + params: { startLedger: sequence, endLedger: sequence }, + }), + }); + if (!response.ok) { + throw new Error(`getLedgers HTTP ${response.status} for ledger ${sequence}`); + } + const body = (await response.json()) as { + result?: { ledgers?: Array<{ ledgerCloseTime?: string }> }; + error?: { message?: string }; + }; + if (body.error) { + throw new Error(`getLedgers RPC error for ledger ${sequence}: ${body.error.message ?? "unknown"}`); + } + const ledger = body.result?.ledgers?.[0]; + return ledger ? { header: { closeTime: ledger.ledgerCloseTime } } : {}; + } + + getEvents(request: SorobanRpc.Server.GetEventsRequest) { + return this.server.getEvents(request); + } + + /** + * Read raw ledger entries by key (contract-version probing reads a + * contract instance's WASM hash this way, with no simulation involved). + */ + getLedgerEntries(...keys: xdr.LedgerKey[]): Promise { + return this.server.getLedgerEntries(...keys); + } + + getFeeStats(): Promise { + return this.server.getFeeStats(); + } + + simulateTransaction( + transaction: Transaction, + ): Promise { + return this.server.simulateTransaction(transaction); + } + + prepareTransaction( + transaction: Transaction, + ): Promise { + return this.server.prepareTransaction(transaction) as Promise; + } + + submitTransaction(transaction: Transaction | FeeBumpTransaction): Promise { + return this.server.sendTransaction(transaction); + } + + getTransaction(hash: string): Promise { + return this.server.getTransaction(hash); + } +} diff --git a/src/soroban/stellar-tx.before-submit.spec.ts b/src/soroban/stellar-tx.before-submit.spec.ts index d9f0b3b9..f5ea4629 100644 --- a/src/soroban/stellar-tx.before-submit.spec.ts +++ b/src/soroban/stellar-tx.before-submit.spec.ts @@ -53,7 +53,6 @@ describe("StellarTxService.invokeContract beforeSubmit (#396)", () => { signer as unknown as SignerService, confirmation as unknown as TxConfirmationService, config as unknown as ConfigService, - undefined, { evaluateTarget: () => ({ paused: false }) } as unknown as KillSwitchService, ); }); diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index 343dfff4..c8fb80ce 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -30,11 +30,13 @@ import { Injectable, Logger, Optional } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Account, + Address, BASE_FEE, Contract, FeeBumpTransaction, Operation, SorobanDataBuilder, + nativeToScVal, Networks, SorobanRpc, Transaction, @@ -87,6 +89,24 @@ export interface InvokeContractParams { args: xdr.ScVal[]; } +/** + * Time bound (seconds) on every transaction built by {@link StellarTxService.invokeContract}. + * After this the network rejects the envelope, which is what lets the outbox + * relay treat a NOT_FOUND envelope hash as "never landed, safe to rebuild" + * once its processing lease (OUTBOX_LEASE_SECONDS) has expired (issue #396). + */ +export const INVOKE_TX_TIMEOUT_SECONDS = 30; + +export interface InvokeContractOptions { + /** + * Called with the signed envelope's hash after signing and *before* + * broadcast (issue #396). If it throws, nothing is submitted. The outbox + * relay uses this to durably record the hash so a crash mid-submit can be + * detected on retry instead of double-submitting. + */ + beforeSubmit?: (envelopeHash: string) => Promise; +} + export interface InvokeContractResult { hash: string; status: string; @@ -158,6 +178,11 @@ export class StellarTxService { private readonly signerService: SignerService, private readonly confirmationService: TxConfirmationService, configService: ConfigService, + /** + * Emergency pause control plane (issue #477). Required: the module that + * owns this service runs under the global KillSwitchModule, and a missing + * kill switch would fail open on every write path. + */ private readonly killSwitch: KillSwitchService, @Optional() private readonly metricsService?: MetricsService, @Optional() private readonly flags?: FeatureFlagService, @@ -263,7 +288,10 @@ export class StellarTxService { * 3. Sign and submit the (now-prepared) original transaction. * 4. Confirm and return the result. */ - async invokeContract(params: InvokeContractParams): Promise { + async invokeContract( + params: InvokeContractParams, + options: InvokeContractOptions = {}, + ): Promise { // Issue #477 — the last gate before anything touches the chain. Checking // here rather than only in controllers also covers background callers (the // sweeper, event ingestion) that never pass through an HTTP guard. @@ -300,7 +328,7 @@ export class StellarTxService { .addOperation( new Contract(params.contractId).call(params.method, ...params.args), ) - .setTimeout(30) + .setTimeout(INVOKE_TX_TIMEOUT_SECONDS) .build(); let simulation = await this.sorobanService.simulateTransaction(rawTx); @@ -331,6 +359,8 @@ export class StellarTxService { const prepared = await this.sorobanService.prepareTransaction(rawTx); const signed = await this.signerService.sign(prepared as Transaction); + await options.beforeSubmit?.(signed.hash().toString("hex")); + const submittedAt = Date.now(); const sendResponse = await this.sorobanService.submitTransaction(signed); @@ -599,6 +629,7 @@ export class StellarTxService { ): Promise { const baseFee = await this.estimateBaseFee(); const sequence = await this.resolveSimulationSequence(sourceAccount); + const contract = new Contract(params.contractId); // `TransactionBuilder` emits `source.sequenceNumber() + 1` as the envelope's // seqNum, so the account handed to it must sit one *below* the sequence the @@ -615,10 +646,10 @@ export class StellarTxService { fee: baseFee, networkPassphrase: this.networkPassphrase, }) - // Same envelope shape as `invokeContract` builds for the live path — - // the monitor is only useful if it simulates the call the chain would - // actually receive. - .addOperation(new Contract(params.contractId).call(params.method, ...params.args)) + // Contract.call encodes the invoke-host-function operation (method name + // as an ScSymbol, args as ScVals) exactly the way every other call site + // in this codebase builds one. + .addOperation(contract.call(params.method, ...params.args)) .setTimebounds(now, now + this.simulationTimeoutSeconds) .build(); } diff --git a/src/soroban/tx-confirmation.service.spec.ts b/src/soroban/tx-confirmation.service.spec.ts index e69de29b..f4814bfb 100644 --- a/src/soroban/tx-confirmation.service.spec.ts +++ b/src/soroban/tx-confirmation.service.spec.ts @@ -0,0 +1,203 @@ +import { SorobanRpc } from "@stellar/stellar-sdk"; +import { TxConfirmationService, TrackTxOptions } from "./tx-confirmation.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { MetricsService } from "../metrics/metrics.service"; +import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; +import { FeeEscalationPolicy } from "./fee-escalation-policy"; +import { TxConfirmed, TxFailed, TxExpired } from "./tx-events"; + +// EventEmitter2 is ESM-only; mock it for Jest's CommonJS environment +jest.mock("@nestjs/event-emitter", () => ({ + EventEmitter2: jest.fn().mockImplementation(() => ({ emit: jest.fn() })), +})); + +function makePrisma(overrides: Partial<{ + queryRaw: jest.Mock; + update: jest.Mock; + upsert: jest.Mock; +}> = {}): PrismaService { + return { + pendingTransaction: { + upsert: overrides.upsert ?? jest.fn().mockResolvedValue({}), + update: overrides.update ?? jest.fn().mockResolvedValue({}), + }, + $queryRaw: overrides.queryRaw ?? jest.fn().mockResolvedValue([]), + } as unknown as PrismaService; +} + +function makeSoroban( + getTransactionResult: SorobanRpc.Api.GetTransactionResponse, +): SorobanService { + return { + getTransaction: jest.fn().mockResolvedValue(getTransactionResult), + submitTransaction: jest.fn().mockResolvedValue({ status: "PENDING" }), + } as unknown as SorobanService; +} + +function makeSigner(configured = false): SignerService { + return { + isConfigured: jest.fn().mockReturnValue(configured), + getNetworkPassphrase: jest.fn().mockReturnValue("Test SDF Network ; September 2015"), + getSecretKey: jest.fn().mockReturnValue(""), + } as unknown as SignerService; +} + +function makeMetrics(): MetricsService { + return { + txConfirmationOutcomes: { inc: jest.fn() }, + txConfirmationLatency: { observe: jest.fn() }, + } as unknown as MetricsService; +} + +function makeFeePolicy(shouldEscalate = false): FeeEscalationPolicy { + return { + shouldEscalate: jest.fn().mockReturnValue(shouldEscalate), + buildFeeBump: jest.fn().mockResolvedValue(null), + } as unknown as FeeEscalationPolicy; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeEvents(): any { + return { emit: jest.fn() }; +} + +const BASE_ROW = { + id: 1n, + tx_hash: "abc123", + tx_xdr: "AAAA", + intent_id: "intent-1", + channel_key: null, + status: "pending", + max_track_until: Math.floor(Date.now() / 1000) + 300, + attempts: 0, + next_poll_at: Math.floor(Date.now() / 1000) - 1, + last_fee_stroops: null, + fee_bump_count: 0, + created_at: new Date(), +}; + +describe("TxConfirmationService", () => { + describe("track()", () => { + it("upserts a pending transaction record", async () => { + const upsert = jest.fn().mockResolvedValue({}); + const prisma = makePrisma({ upsert }); + const svc = new TxConfirmationService( + prisma, + makeSoroban({ status: SorobanRpc.Api.GetTransactionStatus.NOT_FOUND } as SorobanRpc.Api.GetTransactionResponse), + makeSigner(), + makeFeePolicy(), + makeMetrics(), + makeEvents(), + ); + + const opts: TrackTxOptions = { + txHash: "abc123", + txXdr: "AAAA", + intentId: "intent-1", + maxTrackUntil: Math.floor(Date.now() / 1000) + 300, + }; + await svc.track(opts); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { txHash: "abc123" }, + create: expect.objectContaining({ txHash: "abc123", status: "pending" }), + }), + ); + }); + }); + + describe("processRow() via pollBatch()", () => { + it("marks confirmed and emits TxConfirmed on SUCCESS", async () => { + const update = jest.fn().mockResolvedValue({}); + const prisma = makePrisma({ queryRaw: jest.fn().mockResolvedValue([BASE_ROW]), update }); + const soroban = makeSoroban({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + ledger: 42, + } as SorobanRpc.Api.GetSuccessfulTransactionResponse); + const events = makeEvents(); + const metrics = makeMetrics(); + + const svc = new TxConfirmationService( + prisma, soroban, makeSigner(), makeFeePolicy(), metrics, events, + ); + + // Call the private pollBatch via onModuleInit's interval—instead directly + // trigger it by casting to any + await (svc as unknown as { pollBatch: () => Promise }).pollBatch(); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "confirmed" } }), + ); + expect(events.emit).toHaveBeenCalledWith(TxConfirmed.EVENT, expect.any(TxConfirmed)); + expect(metrics.txConfirmationOutcomes.inc).toHaveBeenCalledWith({ status: "confirmed" }); + }); + + it("marks failed and emits TxFailed on FAILED (no fee bump)", async () => { + const update = jest.fn().mockResolvedValue({}); + const prisma = makePrisma({ queryRaw: jest.fn().mockResolvedValue([BASE_ROW]), update }); + const soroban = makeSoroban({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + } as SorobanRpc.Api.GetFailedTransactionResponse); + const events = makeEvents(); + const metrics = makeMetrics(); + + const svc = new TxConfirmationService( + prisma, soroban, makeSigner(false), makeFeePolicy(false), metrics, events, + ); + await (svc as unknown as { pollBatch: () => Promise }).pollBatch(); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "failed" } }), + ); + expect(events.emit).toHaveBeenCalledWith(TxFailed.EVENT, expect.any(TxFailed)); + }); + + it("schedules retry with backoff on NOT_FOUND", async () => { + const update = jest.fn().mockResolvedValue({}); + const prisma = makePrisma({ queryRaw: jest.fn().mockResolvedValue([BASE_ROW]), update }); + const soroban = makeSoroban({ + status: SorobanRpc.Api.GetTransactionStatus.NOT_FOUND, + } as SorobanRpc.Api.GetTransactionResponse); + + const svc = new TxConfirmationService( + prisma, soroban, makeSigner(), makeFeePolicy(false), makeMetrics(), makeEvents(), + ); + await (svc as unknown as { pollBatch: () => Promise }).pollBatch(); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ attempts: 1 }), + }), + ); + }); + + it("marks expired and emits TxExpired when past maxTrackUntil", async () => { + const expiredRow = { + ...BASE_ROW, + max_track_until: Math.floor(Date.now() / 1000) - 10, // already expired + }; + const update = jest.fn().mockResolvedValue({}); + const prisma = makePrisma({ queryRaw: jest.fn().mockResolvedValue([expiredRow]), update }); + const events = makeEvents(); + const metrics = makeMetrics(); + + const svc = new TxConfirmationService( + prisma, + makeSoroban({ status: SorobanRpc.Api.GetTransactionStatus.NOT_FOUND } as SorobanRpc.Api.GetTransactionResponse), + makeSigner(), + makeFeePolicy(), + metrics, + events, + ); + await (svc as unknown as { pollBatch: () => Promise }).pollBatch(); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "expired" } }), + ); + expect(events.emit).toHaveBeenCalledWith(TxExpired.EVENT, expect.any(TxExpired)); + expect(metrics.txConfirmationOutcomes.inc).toHaveBeenCalledWith({ status: "expired" }); + }); + }); +}); diff --git a/src/soroban/tx-confirmation.service.ts b/src/soroban/tx-confirmation.service.ts index 7643ba39..43d96615 100644 --- a/src/soroban/tx-confirmation.service.ts +++ b/src/soroban/tx-confirmation.service.ts @@ -1,24 +1,65 @@ /** - * TxConfirmationService (issue #394) - * ──────────────────────────────────── - * Polls the Soroban RPC until a submitted transaction reaches a terminal - * status (SUCCESS or FAILED) or the configured timeout elapses. + * TxConfirmationService (issues #386, #394) + * ─────────────────────────────────────────── + * Two confirmation styles over the same pending-transaction ledger: * - * Used by StellarTxService after every live-path submitTransaction call so - * callers receive a definitive result rather than an optimistic "sent". + * 1. `check()` / `waitForConfirmation()` — direct lookups used by callers + * that hold the hash in memory (the outbox relay, the slashing saga, and + * StellarTxService's live submit path). + * 2. `track()` + the poll batch — durable tracking for submissions whose + * outcome must survive a process restart: every tracked envelope lands in + * `pending_transactions`, and a background batch re-polls due rows until + * they confirm, fail (fee-bump recovery permitting), or age out past + * `maxTrackUntil`. * - * Metrics: records fill-to-confirmation latency via MetricsService - * (vortex_tx_confirmation_duration_seconds, SLO SLI from issue #480). + * Metrics: `vortex_tx_confirmation_outcomes_total` counts terminal outcomes + * and `vortex_tx_confirmation_duration_seconds` records confirmation latency + * (SLO SLI from issue #480). */ -import { Injectable, Logger, Optional } from "@nestjs/common"; -import { SorobanRpc } from "@stellar/stellar-sdk"; -import { SorobanService } from "./soroban.service"; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { FeeBumpTransaction, SorobanRpc } from "@stellar/stellar-sdk"; +import { PrismaService } from "../prisma/prisma.service"; import { MetricsService } from "../metrics/metrics.service"; +import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; +import { FeeEscalationPolicy } from "./fee-escalation-policy"; +import { TxConfirmed, TxExpired, TxFailed } from "./tx-events"; const DEFAULT_POLL_INTERVAL_MS = 3_000; const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes +/** How often the durable poll batch wakes up (issue #386). */ +const POLL_BATCH_INTERVAL_MS = 5_000; +/** Maximum due rows claimed per poll batch. */ +const POLL_BATCH_SIZE = 50; +/** Base delay before re-polling a NOT_FOUND row; doubles per attempt. */ +const RETRY_BASE_DELAY_S = 5; +/** Cap on the exponential NOT_FOUND backoff. */ +const RETRY_MAX_DELAY_S = 300; + +/** + * Normalized outcome of a single, non-blocking lookup ({@link TxConfirmationService.check}). + * + * success — applied successfully in a closed ledger. + * failed — included in a ledger but the invocation failed. + * not_found — unknown to the RPC node: still pending, dropped, or expired + * past its time bound. Callers decide which using their own + * notion of elapsed time. + */ +export type TxConfirmationStatus = "success" | "failed" | "not_found"; + +/** Result of {@link TxConfirmationService.check} (issues #396 / #397). */ +export interface TxConfirmation { + status: TxConfirmationStatus; + /** Ledger the transaction was included in (success/failed only). */ + ledger?: number; + /** Ledger close time, unix seconds (success/failed only). */ + ledgerCloseTime?: number; +} + +/** Blocking-poll outcome used by the live submit path (issue #394). */ export interface ConfirmationResult { hash: string; status: "SUCCESS" | "FAILED" | "TIMEOUT"; @@ -30,15 +71,267 @@ export interface ConfirmationResult { durationMs: number; } +/** Options for {@link TxConfirmationService.track} (issue #386). */ +export interface TrackTxOptions { + /** Transaction hash returned by `sendTransaction`. */ + txHash: string; + /** Base64 signed envelope, kept for fee-bump resubmission. */ + txXdr: string; + /** Intent this submission belongs to, when it has one. */ + intentId?: string; + /** Channel account used for submission, when a channel key signed it. */ + channelKey?: string; + /** Unix seconds after which tracking is abandoned and the row expires. */ + maxTrackUntil: number; +} + +/** One `pending_transactions` row as returned by the poll batch query. */ +interface PendingTxRow { + id: bigint; + tx_hash: string; + tx_xdr: string; + intent_id: string | null; + channel_key: string | null; + status: string; + max_track_until: number; + attempts: number; + next_poll_at: number; + last_fee_stroops: string | null; + fee_bump_count: number; + created_at: Date; +} + @Injectable() -export class TxConfirmationService { +export class TxConfirmationService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(TxConfirmationService.name); + private timer?: NodeJS.Timeout; constructor( + private readonly prisma: PrismaService, private readonly sorobanService: SorobanService, + private readonly signerService: SignerService, + private readonly feePolicy: FeeEscalationPolicy, @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly events?: EventEmitter2, ) {} + // ── Durable tracking (issue #386) ───────────────────────────────────────── + + /** + * Persist a submission for durable tracking. Idempotent per hash: a + * re-track resets the row to `pending` with a fresh time bound so a + * resumed workflow picks the envelope up again after a restart. + */ + async track(opts: TrackTxOptions): Promise { + const now = Math.floor(Date.now() / 1000); + await this.prisma.pendingTransaction.upsert({ + where: { txHash: opts.txHash }, + create: { + txHash: opts.txHash, + txXdr: opts.txXdr, + intentId: opts.intentId ?? null, + channelKey: opts.channelKey ?? null, + status: "pending", + maxTrackUntil: opts.maxTrackUntil, + attempts: 0, + nextPollAt: now, + }, + update: { + txXdr: opts.txXdr, + intentId: opts.intentId ?? null, + channelKey: opts.channelKey ?? null, + status: "pending", + maxTrackUntil: opts.maxTrackUntil, + attempts: 0, + nextPollAt: now, + }, + }); + } + + onModuleInit(): void { + this.timer = setInterval(() => { + void this.pollBatch().catch((err: unknown) => { + this.logger.warn(`[tx-confirmation] poll batch failed: ${(err as Error).message}`); + }); + }, POLL_BATCH_INTERVAL_MS); + // Never hold the process open just to poll. + this.timer.unref?.(); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + /** + * Claim due pending rows and drive each toward a terminal state. One row's + * RPC failure never aborts the batch — the row stays pending and is + * re-polled on the next wake-up. + */ + private async pollBatch(): Promise { + const rows = await this.prisma.$queryRaw` + SELECT id, tx_hash, tx_xdr, intent_id, channel_key, status, max_track_until, + attempts, next_poll_at, last_fee_stroops, fee_bump_count, created_at + FROM pending_transactions + WHERE status = 'pending' + AND next_poll_at <= EXTRACT(EPOCH FROM NOW())::bigint + ORDER BY id + LIMIT ${POLL_BATCH_SIZE} + `; + for (const row of rows) { + try { + await this.processRow(row); + } catch (err) { + this.logger.warn( + `[tx-confirmation] poll failed for ${row.tx_hash}: ${(err as Error).message}`, + ); + } + } + } + + /** Drive one pending row to a terminal state, a retry, or a fee bump. */ + private async processRow(row: PendingTxRow): Promise { + const now = Math.floor(Date.now() / 1000); + const latencyMs = + row.created_at instanceof Date ? Math.max(0, Date.now() - row.created_at.getTime()) : 0; + + // Time bound first: an expired row must not burn an RPC round-trip, and + // its terminal outcome is decided locally. + if (row.max_track_until <= now) { + await this.prisma.pendingTransaction.update({ + where: { id: row.id }, + data: { status: "expired" }, + }); + this.events?.emit(TxExpired.EVENT, new TxExpired(row.tx_hash, row.intent_id, latencyMs)); + this.metricsService?.txConfirmationOutcomes.inc({ status: "expired" }); + this.logger.warn(`[tx-confirmation] tracking expired hash=${row.tx_hash}`); + return; + } + + const response = await this.sorobanService.getTransaction(row.tx_hash); + + if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + await this.prisma.pendingTransaction.update({ + where: { id: row.id }, + data: { status: "confirmed" }, + }); + this.events?.emit( + TxConfirmed.EVENT, + new TxConfirmed(row.tx_hash, row.intent_id, response.ledger, latencyMs), + ); + this.metricsService?.txConfirmationOutcomes.inc({ status: "confirmed" }); + this.logger.log(`[tx-confirmation] confirmed hash=${row.tx_hash} ledger=${response.ledger}`); + return; + } + + if (response.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + if (await this.maybeFeeBump(row)) return; + await this.prisma.pendingTransaction.update({ + where: { id: row.id }, + data: { status: "failed" }, + }); + let errorCode = "tx_failed"; + try { + errorCode = response.resultXdr.result().switch().name || errorCode; + } catch { + /* decoded detail is best-effort; the terminal state is what matters */ + } + this.events?.emit(TxFailed.EVENT, new TxFailed(row.tx_hash, row.intent_id, errorCode, latencyMs)); + this.metricsService?.txConfirmationOutcomes.inc({ status: "failed" }); + this.logger.warn(`[tx-confirmation] failed hash=${row.tx_hash} code=${errorCode}`); + return; + } + + // NOT_FOUND: still pending or dropped — escalate when the policy says the + // time bound is close, otherwise retry with capped exponential backoff. + if (await this.maybeFeeBump(row)) return; + const delayS = Math.min(RETRY_BASE_DELAY_S * 2 ** Math.min(row.attempts, 8), RETRY_MAX_DELAY_S); + await this.prisma.pendingTransaction.update({ + where: { id: row.id }, + data: { attempts: row.attempts + 1, nextPollAt: now + delayS }, + }); + } + + /** + * Apply the fee-escalation ladder when the policy asks for it. + * + * Returns true when a bumped envelope was built and submitted (the row + * follows the new hash and stays pending), false when escalation is not + * warranted or not possible — remote signer backends never expose key + * material, so they deliberately fall through to the terminal path. + */ + private async maybeFeeBump(row: PendingTxRow): Promise { + const now = Math.floor(Date.now() / 1000); + if ( + !this.feePolicy.shouldEscalate({ + feeBumpCount: row.fee_bump_count, + maxTrackUntil: row.max_track_until, + currentFeeStroops: row.last_fee_stroops ?? "0", + }) + ) { + return false; + } + if (!this.signerService.isConfigured()) return false; + const feeSource = this.signerService.getFeeSourceKeypair(); + if (!feeSource) return false; + + const networkPassphrase = this.signerService.getNetworkPassphrase(); + const bump = await this.feePolicy.buildFeeBump({ + innerTxXdr: row.tx_xdr, + feeSourceKeypair: feeSource, + networkPassphrase, + feeBumpCount: row.fee_bump_count, + }); + if (!bump) return false; + + const envelope = new FeeBumpTransaction(bump.feeBumpXdr, networkPassphrase); + const sent = await this.sorobanService.submitTransaction(envelope); + if (sent.status === "ERROR") { + this.logger.warn(`[tx-confirmation] fee-bump submit rejected for ${row.tx_hash}`); + return false; + } + + const bumpedHash = envelope.hash().toString("hex").toUpperCase(); + await this.prisma.pendingTransaction.update({ + where: { id: row.id }, + data: { + txHash: bumpedHash, + feeBumpCount: row.fee_bump_count + 1, + lastFeeStroops: bump.newFeeStroops, + attempts: row.attempts + 1, + nextPollAt: now + RETRY_BASE_DELAY_S, + }, + }); + this.logger.log( + `[tx-confirmation] fee-bumped ${row.tx_hash} -> ${bumpedHash} (bump #${row.fee_bump_count + 1})`, + ); + return true; + } + + // ── Direct lookups ──────────────────────────────────────────────────────── + + /** + * Single, non-blocking lookup of `hash` (issues #396 / #397). + * + * For durable callers — the outbox relay and the slashing saga — that + * persist the hashes they wait on and re-check on their own schedule, so a + * restart never loses an in-flight transaction and a worker tick never + * blocks for the full {@link waitForConfirmation} timeout. RPC transport + * errors propagate so callers can retry rather than mistake an outage for + * NOT_FOUND. + */ + async check(hash: string): Promise { + const response = await this.sorobanService.getTransaction(hash); + switch (response.status) { + case SorobanRpc.Api.GetTransactionStatus.SUCCESS: + return { status: "success", ledger: response.ledger, ledgerCloseTime: response.createdAt }; + case SorobanRpc.Api.GetTransactionStatus.FAILED: + this.logger.warn(`[tx-confirmation] tx ${hash} failed in ledger ${response.ledger}`); + return { status: "failed", ledger: response.ledger, ledgerCloseTime: response.createdAt }; + default: + return { status: "not_found" }; + } + } + /** * Poll until the transaction identified by `hash` reaches a terminal state. * @@ -87,9 +380,16 @@ export class TxConfirmationService { return { hash, status: "SUCCESS", response, durationMs }; } - // FAILED: `response` is narrowed to the failed variant, whose `resultXdr` - // is a decoded xdr.TransactionResult (not a string). - const errorDetail = response.resultXdr.result().switch().name; + // FAILED: extract the result-code name when the decoded result is present. + let errorDetail = "unknown"; + try { + errorDetail = + (response as { resultXdr?: { result(): { switch(): { name: string } } } }).resultXdr + ?.result() + .switch().name ?? errorDetail; + } catch { + /* keep the generic detail */ + } this.logger.warn( `[tx-confirmation] FAILED hash=${hash} durationMs=${durationMs} detail=${errorDetail}`, ); diff --git a/src/stats/stats.service.ts b/src/stats/stats.service.ts index beec2fce..fe6adad7 100644 --- a/src/stats/stats.service.ts +++ b/src/stats/stats.service.ts @@ -1,30 +1,3 @@ -import { Injectable } from "@nestjs/common"; -import { IntentsService } from "../intents/intents.service"; -import { SolversService } from "../solvers/solvers.service"; -import { IntentsGateway } from "../intents/intents.gateway"; - -/** - * Protocol statistics returned by `GET /api/v1/stats` and - * `GET /api/v1/stats/public`. - */ -export interface ProtocolStats { - totalIntents: number; - openIntents: number; - totalVolume: string; - uniqueUsers: number; - activeSolvers: number; - avgFillTime: number; - fillRate: number; -} - -/** - * Aggregated statistics for the protocol dashboard (#481). - * - * All computations are pure functions over the current repository state - * so they can be tested without a database (see stats.service.spec.ts). - * Heavy Prisma queries in the future should be gated behind the - * PrismaService.withStatsTimeout() helper. - */ import { Injectable, Optional } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AppConfig } from "../config/configuration"; @@ -40,91 +13,6 @@ export class StatsService { private readonly intentsService: IntentsService, private readonly solversService: SolversService, private readonly intentsGateway: IntentsGateway, - ) {} - - /** - * Full protocol statistics (internal/admin consumers). - */ - async getProtocolStats(): Promise { - const [intents, solvers] = await Promise.all([ - this.intentsService.getAll(), - this.solversService.getAll(), - ]); - - const totalIntents = intents.length; - const openIntents = intents.filter((i) => i.state === "open").length; - const activeSolvers = solvers.filter((s) => s.isActive).length; - - // Total fill volume — BigInt arithmetic to avoid precision loss. - const totalVolume = intents - .filter((i) => i.state === "filled" && i.fillAmount) - .reduce((sum, i) => { - try { - return sum + BigInt(i.fillAmount!); - } catch { - return sum; - } - }, 0n) - .toString(); - - // Unique user addresses (case-insensitive). - const uniqueUsers = new Set(intents.map((i) => i.user.toLowerCase())).size; - - // Average fill time across all filled intents that have a filledAt. - const filledWithTime = intents.filter( - (i) => i.state === "filled" && typeof i.filledAt === "number", - ); - const avgFillTime = - filledWithTime.length === 0 - ? 0 - : Math.round( - filledWithTime.reduce((sum, i) => sum + (i.filledAt! - i.createdAt), 0) / - filledWithTime.length, - ); - - // Fill rate = filled / total (0 when no intents at all). - const filledCount = intents.filter((i) => i.state === "filled").length; - const fillRate = totalIntents === 0 ? 0 : filledCount / totalIntents; - - return { - totalIntents, - openIntents, - totalVolume, - uniqueUsers, - activeSolvers, - avgFillTime, - fillRate, - }; - } - - /** - * Public-facing stats (excludes canary addresses, solver internals). - */ - async getPublicStats(): Promise { - return this.getProtocolStats(); - } - - /** - * Historical stats stub — future implementation will pull from - * TimescaleDB continuous aggregates. - */ - async getPublicStatsHistory(): Promise { - return []; - } - - /** - * Treasury stats stub. - */ - async getTreasuryStats(): Promise { - return {}; - } - - /** - * WebSocket gateway stats (active subscriber count etc.). - */ - async getWsStats(): Promise<{ subscribers: number }> { - return { - subscribers: this.intentsGateway.getSubscriberCount(), @Optional() config?: ConfigService, ) { this.canary = new Set(config?.get("canaryAddresses", { infer: true }) ?? []); diff --git a/src/tokens/admin-tokens.controller.spec.ts b/src/tokens/admin-tokens.controller.spec.ts index d88f6542..47913cbe 100644 --- a/src/tokens/admin-tokens.controller.spec.ts +++ b/src/tokens/admin-tokens.controller.spec.ts @@ -4,6 +4,7 @@ import { Reflector } from "@nestjs/core"; import { Test } from "@nestjs/testing"; import request from "supertest"; import { AdminGuard } from "../admin/admin.guard"; +import { enableApiVersioning } from "../common/api-versioning"; import { AdminTokensController } from "./admin-tokens.controller"; import { AdminTokensService } from "./admin-tokens.service"; @@ -28,6 +29,9 @@ describe("AdminTokensController RBAC", () => { ], }).compile(); app = moduleRef.createNestApplication(); + // URI versioning with the `api/v` prefix is what turns the controller's + // `admin/tokens` path into the /api/v1/admin/tokens route under test. + enableApiVersioning(app); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); await app.init(); }); diff --git a/src/tokens/in-memory-tokens.repository.ts b/src/tokens/in-memory-tokens.repository.ts index ed263c82..b4e8a793 100644 --- a/src/tokens/in-memory-tokens.repository.ts +++ b/src/tokens/in-memory-tokens.repository.ts @@ -1,8 +1,9 @@ import { SupportedChain } from "../intents/intents.types"; import { STELLAR_TOKENS, SUPPORTED_TOKENS } from "./tokens.data"; -import { ITokensRepository, TokenRecord } from "./tokens.repository"; +import { ITokensRepository, TokenRecord, TokenStatus } from "./tokens.repository"; export class InMemoryTokensRepository implements ITokensRepository { + private generation = 0; private readonly records: TokenRecord[] = [ ...Object.entries(SUPPORTED_TOKENS).flatMap(([chain, tokens]) => tokens.map((token) => ({ @@ -46,4 +47,37 @@ export class InMemoryTokensRepository implements ITokensRepository { ); return match ? { ...match } : undefined; } + + /** + * Insert or replace the row for `(address, chain)` and bump the cache + * generation so callers can observe the invalidation (issue #404). + */ + async save(record: TokenRecord): Promise { + const stored: TokenRecord = { ...record, status: record.status ?? "active" }; + const normalizedAddress = record.address.trim().toLowerCase(); + const chainName = String(record.chain).toLowerCase(); + const index = this.records.findIndex( + (existing) => + existing.address.toLowerCase() === normalizedAddress && existing.chain === chainName, + ); + if (index >= 0) this.records[index] = stored; + else this.records.push(stored); + this.generation += 1; + return { ...stored }; + } + + /** Soft status change; undefined when the token is not registered. */ + async setStatus( + address: string, + chain: SupportedChain | string, + status: TokenStatus, + ): Promise { + const existing = this.findByAddressAndChain(address, chain); + if (!existing) return undefined; + return this.save({ ...existing, status }); + } + + cacheGeneration(): number { + return this.generation; + } } diff --git a/src/tokens/prisma-tokens.repository.ts b/src/tokens/prisma-tokens.repository.ts index 2486013a..ec178f40 100644 --- a/src/tokens/prisma-tokens.repository.ts +++ b/src/tokens/prisma-tokens.repository.ts @@ -1,11 +1,13 @@ import { Injectable } from "@nestjs/common"; +import { TokenStatus as PrismaTokenStatus } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { SupportedChain } from "../intents/intents.types"; -import { ITokensRepository, TokenRecord } from "./tokens.repository"; +import { ITokensRepository, TokenAssetKind, TokenRecord, TokenStatus } from "./tokens.repository"; @Injectable() export class PrismaTokensRepository implements ITokensRepository { private records: TokenRecord[] = []; + private generation = 0; constructor(private readonly prisma: PrismaService) {} @@ -29,12 +31,50 @@ export class PrismaTokensRepository implements ITokensRepository { const normalizedAddress = address.trim().toLowerCase(); const chainName = String(chain).toLowerCase(); const match = this.records.find( - (record) => - record.address.toLowerCase() === normalizedAddress && record.chain === chainName, + (record) => record.address.toLowerCase() === normalizedAddress && record.chain === chainName, ); return match ? { ...match } : undefined; } + /** Persist, then replace the in-memory snapshot so readers see the write. */ + async save(record: TokenRecord): Promise { + const status = (record.status ?? "active") as PrismaTokenStatus; + const data = { + address: record.address, + symbol: record.symbol, + name: record.name, + decimals: record.decimals, + chain: record.chain, + logoUri: record.logoUri ?? null, + priceUsd: record.priceUsd ?? null, + isStellar: record.isStellar, + status, + assetKind: record.assetKind ?? (record.isStellar ? "stellar-sac" : "evm"), + }; + await this.prisma.token.upsert({ + where: { address_chain: { address: record.address, chain: record.chain } }, + create: data, + update: data, + }); + await this.init(); + this.generation += 1; + return this.findByAddressAndChain(record.address, record.chain) ?? { ...record, status: record.status ?? "active" }; + } + + async setStatus( + address: string, + chain: SupportedChain | string, + status: TokenStatus, + ): Promise { + const existing = this.findByAddressAndChain(address, chain); + if (!existing) return undefined; + return this.save({ ...existing, status }); + } + + cacheGeneration(): number { + return this.generation; + } + private fromRow(row: { id?: string; address: string; @@ -45,6 +85,8 @@ export class PrismaTokensRepository implements ITokensRepository { logoUri?: string | null; priceUsd?: number | null; isStellar: boolean; + status?: PrismaTokenStatus; + assetKind?: string; }): TokenRecord { return { id: row.id, @@ -56,6 +98,8 @@ export class PrismaTokensRepository implements ITokensRepository { logoUri: row.logoUri ?? null, priceUsd: row.priceUsd ?? null, isStellar: row.isStellar, + status: row.status ?? "active", + assetKind: (row.assetKind as TokenAssetKind | undefined) ?? (row.isStellar ? "stellar-sac" : "evm"), }; } } diff --git a/src/tokens/tokens.module.ts b/src/tokens/tokens.module.ts index 2995761c..5e50bec8 100644 --- a/src/tokens/tokens.module.ts +++ b/src/tokens/tokens.module.ts @@ -5,6 +5,8 @@ import { TokensService } from "./tokens.service"; import { TOKENS_REPOSITORY } from "./tokens.repository"; import { InMemoryTokensRepository } from "./in-memory-tokens.repository"; import { PrismaTokensRepository } from "./prisma-tokens.repository"; +import { PriceFeedWorker } from "./price-feed.worker"; +import { CoinGeckoPriceFeedProvider, PRICE_FEED_PROVIDER } from "./price-feed.provider"; @Module({ controllers: [TokensController], diff --git a/src/tokens/tokens.repository.ts b/src/tokens/tokens.repository.ts index f0dcf027..2c091e28 100644 --- a/src/tokens/tokens.repository.ts +++ b/src/tokens/tokens.repository.ts @@ -1,5 +1,11 @@ import { SupportedChain } from "../intents/intents.types"; +/** Discovery and create-path lifecycle. Delisted rows are retained. */ +export type TokenStatus = "active" | "paused" | "delisted"; + +/** How the address was verified. Classic Stellar assets are not SACs. */ +export type TokenAssetKind = "evm" | "stellar-sac" | "stellar-classic"; + export interface TokenRecord { id?: string; address: string; @@ -10,6 +16,9 @@ export interface TokenRecord { logoUri?: string | null; priceUsd?: number | null; isStellar: boolean; + /** Missing on older in-memory seeds is treated as active. */ + status?: TokenStatus; + assetKind?: TokenAssetKind; } export const TOKENS_REPOSITORY = Symbol("TOKENS_REPOSITORY"); @@ -18,4 +27,13 @@ export interface ITokensRepository { findAll(): TokenRecord[]; findByChain(chain: SupportedChain | string): TokenRecord[]; findByAddressAndChain(address: string, chain: SupportedChain | string): TokenRecord | undefined; + /** + * Insert or replace the row for `(address, chain)` and drop any cached copy. + * Must not be called with metadata that failed on-chain verification. + */ + save(record: TokenRecord): Promise; + /** Soft status change. Returns undefined when the token is not registered. */ + setStatus(address: string, chain: SupportedChain | string, status: TokenStatus): Promise; + /** Bumps on every successful mutation so callers can observe cache invalidation. */ + cacheGeneration(): number; } diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index 90d7b396..56c54ab8 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import { SUPPORTED_TOKENS, StellarToken } from "./tokens.data"; import { SupportedChain } from "../intents/intents.types"; -import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord } from "./tokens.repository"; +import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord, TokenStatus } from "./tokens.repository"; /** * A resolved source-chain (EVM or Stellar source) token — always has a @@ -68,15 +68,7 @@ export class TokensService { async resolveSrcToken(chain: SupportedChain, address: string): Promise { const token = await this.repo.findByAddressAndChain(address, chain); if (!token) return undefined; - return { - kind: "src", - address: token.address, - symbol: token.symbol, - name: token.name, - decimals: token.decimals, - chain, - priceUSD: token.priceUsd ?? 0, - }; + return this.toResolvedSrcToken(token, chain); } /** @@ -102,20 +94,42 @@ export class TokensService { * returning `undefined` when the chain + address does not resolve to a token * in the configured registry (issue #276). * - * Use this on the write path (intent creation) where an unrecognised token - * must be rejected outright rather than silently stored with no priceUSD. + * Also rejects paused and delisted tokens: this sits on the write path + * (intent creation, quoting), where a token that admins have taken out of + * rotation must not spawn new activity. Deliberately *not* used on the + * fill/settlement path — an already-created intent keeps working against + * its copied srcToken after its registry entry is delisted. */ async resolveSrcTokenOrThrow( chain: SupportedChain, address: string, ): Promise { - const token = await this.resolveSrcToken(chain, address); - if (!token) { + const record = await this.repo.findByAddressAndChain(address, chain); + if (!record) { throw new BadRequestException( `Unknown source token '${address}' for chain '${chain}' in the configured token registry`, ); } - return token; + const status = record.status ?? "active"; + if (status !== "active") { + throw new BadRequestException( + `Source token '${address}' for chain '${chain}' is ${status}; ${status} tokens cannot be used for new intents or quotes`, + ); + } + return this.toResolvedSrcToken(record, chain); + } + + /** Normalise a stored source-chain token record into the public shape. */ + private toResolvedSrcToken(token: TokenRecord, chain: SupportedChain): ResolvedSrcToken { + return { + kind: "src", + address: token.address, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + chain, + priceUSD: token.priceUsd ?? 0, + }; } /** @@ -152,6 +166,16 @@ export class TokensService { }; } + /** + * Delisted entries disappear from the registry listing — they stay + * resolvable for intents created before the delist (see + * {@link resolveSrcToken}), but must not appear in discovery results. + * Paused tokens remain visible: under review, not retired. + */ + private hideDelisted(records: T[]): T[] { + return records.filter((record) => (record.status ?? "active") !== "delisted"); + } + /** * Return the supported token registry, optionally narrowed to one chain. * @@ -167,7 +191,7 @@ export class TokensService { const requested = chain?.toLowerCase(); if (requested === "stellar") { - const records = await this.repo.findByChain("stellar"); + const records = this.hideDelisted(await this.repo.findByChain("stellar")); return { tokens: records.map((record) => this.toApiToken(record)), chain: "stellar", @@ -175,7 +199,7 @@ export class TokensService { } if (requested && requested in SUPPORTED_TOKENS) { - const records = await this.repo.findByChain(requested); + const records = this.hideDelisted(await this.repo.findByChain(requested)); return { tokens: records .filter((record) => record.chain === requested) @@ -184,7 +208,7 @@ export class TokensService { }; } - const all = await this.repo.findAll(); + const all = this.hideDelisted(await this.repo.findAll()); // Bucket by chain, pre-seeding a key for every chain the static registry // declares so a chain with no rows still appears as an empty array rather diff --git a/src/tracing.ts b/src/tracing.ts index 01c1cbb9..c96c0185 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -38,6 +38,7 @@ import { Link, Span, Context, + Attributes, } from "@opentelemetry/api"; import { ParentBasedSampler, @@ -88,7 +89,7 @@ class AlwaysSampleErrorsAndSlowSampler implements Sampler { traceId: string, spanName: string, spanKind: SpanKind, - attributes: Record, + attributes: Attributes, links: Link[], ): SamplingResult { return this.fallback.shouldSample(ctx, traceId, spanName, spanKind, attributes, links); diff --git a/src/treasury/treasury.service.spec.ts b/src/treasury/treasury.service.spec.ts index e69de29b..9468f36b 100644 --- a/src/treasury/treasury.service.spec.ts +++ b/src/treasury/treasury.service.spec.ts @@ -0,0 +1,507 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { TreasuryService } from "./treasury.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { SorobanService } from "../soroban/soroban.service"; + +describe("TreasuryService", () => { + let service: TreasuryService; + // The mock Prisma only carries the ledger delegates the service touches; + // type as `any` so the per-method `mockResolvedValue` calls type-check + // (jest.Mocked does not deep-transform nested Prisma delegates). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let prisma: any; + let soroban: jest.Mocked; + let configService: jest.Mocked; + + const mockTreasuryAddress = "GTREASURY123456789"; + + beforeEach(async () => { + const mockPrisma = { + feeLedger: { + create: jest.fn(), + findMany: jest.fn(), + }, + slashLedger: { + create: jest.fn(), + findMany: jest.fn(), + }, + refundLedger: { + create: jest.fn(), + findMany: jest.fn(), + }, + treasurySnapshot: { + upsert: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + }, + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === "treasury.address") return mockTreasuryAddress; + if (key === "stellar.horizonUrl") return "https://horizon-testnet.stellar.org"; + if (key === "stellar.sorobanRpcUrl") return "https://soroban-testnet.stellar.org"; + return null; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TreasuryService, + { provide: PrismaService, useValue: mockPrisma }, + { provide: SorobanService, useValue: {} }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(TreasuryService); + prisma = mockPrisma; + // The mock Prisma only carries the ledger delegates the service touches; + // cast to `any` so the per-method `mockResolvedValue` calls type-check + // (jest.Mocked does not deep-transform nested Prisma delegates). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma = module.get(PrismaService) as any; + soroban = module.get(SorobanService) as jest.Mocked; + configService = module.get(ConfigService) as jest.Mocked; + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("recordFee", () => { + it("should record a fee in the ledger", async () => { + const feeEntry = { + intentId: "intent-123", + asset: "native", + amount: "1000000", + accrualAt: new Date(), + txHash: "tx-hash", + }; + + prisma.feeLedger.create.mockResolvedValue({ + id: 1n, + ...feeEntry, + } as any); + + await service.recordFee(feeEntry); + + expect(prisma.feeLedger.create).toHaveBeenCalledWith({ + data: feeEntry, + }); + }); + }); + + describe("recordSlash", () => { + it("should record a slash in the ledger", async () => { + const slashEntry = { + solverAddress: "solver-123", + asset: "native", + amount: "5000000", + slashedAt: new Date(), + reason: "Missed deadline", + txHash: "tx-hash", + }; + + prisma.slashLedger.create.mockResolvedValue({ + id: 1n, + ...slashEntry, + } as any); + + await service.recordSlash(slashEntry); + + expect(prisma.slashLedger.create).toHaveBeenCalledWith({ + data: slashEntry, + }); + }); + }); + + describe("recordRefund", () => { + it("should record a refund in the ledger", async () => { + const refundEntry = { + intentId: "intent-123", + userAddress: "user-123", + asset: "native", + amount: "2000000", + issuedAt: new Date(), + reason: "Failed transaction", + txHash: "tx-hash", + }; + + prisma.refundLedger.create.mockResolvedValue({ + id: 1n, + ...refundEntry, + } as any); + + await service.recordRefund(refundEntry); + + expect(prisma.refundLedger.create).toHaveBeenCalledWith({ + data: refundEntry, + }); + }); + }); + + describe("calculateExpectedBalance", () => { + it("should calculate expected balance from ledgers", async () => { + const asset = "native"; + const now = new Date(); + + prisma.feeLedger.findMany.mockResolvedValue([ + { id: 1n, intentId: "i1", asset, amount: "1000000", accrualAt: now, txHash: null }, + { id: 2n, intentId: "i2", asset, amount: "2000000", accrualAt: now, txHash: null }, + ] as any); + + prisma.slashLedger.findMany.mockResolvedValue([ + { + id: 1n, + solverAddress: "s1", + asset, + amount: "500000", + slashedAt: now, + reason: "test", + txHash: null, + }, + ] as any); + + prisma.refundLedger.findMany.mockResolvedValue([ + { + id: 1n, + intentId: "i3", + userAddress: "u1", + asset, + amount: "300000", + issuedAt: now, + reason: "test", + txHash: null, + }, + ] as any); + + const result = await service.calculateExpectedBalance(asset); + + // 1000000 + 2000000 + 500000 - 300000 = 3200000 + expect(result).toEqual({ + asset: "native", + totalFees: "3000000", + totalSlashes: "500000", + totalRefunds: "300000", + netExpected: "3200000", + }); + }); + + it("should handle empty ledgers", async () => { + const asset = "USDC"; + + prisma.feeLedger.findMany.mockResolvedValue([]); + prisma.slashLedger.findMany.mockResolvedValue([]); + prisma.refundLedger.findMany.mockResolvedValue([]); + + const result = await service.calculateExpectedBalance(asset); + + expect(result).toEqual({ + asset: "USDC", + totalFees: "0", + totalSlashes: "0", + totalRefunds: "0", + netExpected: "0", + }); + }); + }); + + describe("reconcileAsset", () => { + it("should reconcile asset and detect no discrepancy", async () => { + const asset = "native"; + const date = new Date("2026-09-28"); + const expectedBalance = "10000000"; // 1 XLM + + // Mock expected balance calculation + prisma.feeLedger.findMany.mockResolvedValue([ + { + id: 1n, + intentId: "i1", + asset, + amount: expectedBalance, + accrualAt: date, + txHash: null, + }, + ] as any); + prisma.slashLedger.findMany.mockResolvedValue([]); + prisma.refundLedger.findMany.mockResolvedValue([]); + + // Mock actual balance fetch + jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ + asset, + balance: expectedBalance, + }); + + prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); + + const result = await service.reconcileAsset(asset, date); + + expect(result).toMatchObject({ + snapshotDate: "2026-09-28", + asset: "native", + expectedBalance: expectedBalance, + actualBalance: expectedBalance, + discrepancy: "0", + hasUnexplainedDiscrepancy: false, + }); + }); + + it("should detect discrepancy within tolerance", async () => { + const asset = "native"; + const date = new Date("2026-09-28"); + const expectedBalance = "100000000"; // 10 XLM + const actualBalance = "100500000"; // 10.05 XLM (0.5% higher) + + prisma.feeLedger.findMany.mockResolvedValue([ + { + id: 1n, + intentId: "i1", + asset, + amount: expectedBalance, + accrualAt: date, + txHash: null, + }, + ] as any); + prisma.slashLedger.findMany.mockResolvedValue([]); + prisma.refundLedger.findMany.mockResolvedValue([]); + + jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ + asset, + balance: actualBalance, + }); + + prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); + + const result = await service.reconcileAsset(asset, date); + + // Discrepancy is 500000 stroops (0.05 XLM), which is < tolerance of 10000000 (1 XLM) + expect(result).toMatchObject({ + asset: "native", + discrepancy: "500000", + hasUnexplainedDiscrepancy: false, + }); + }); + + it("should detect unexplained discrepancy exceeding tolerance", async () => { + const asset = "native"; + const date = new Date("2026-09-28"); + const expectedBalance = "100000000"; // 10 XLM + const actualBalance = "150000000"; // 15 XLM (5 XLM higher, exceeds 1 XLM tolerance) + + prisma.feeLedger.findMany.mockResolvedValue([ + { + id: 1n, + intentId: "i1", + asset, + amount: expectedBalance, + accrualAt: date, + txHash: null, + }, + ] as any); + prisma.slashLedger.findMany.mockResolvedValue([]); + prisma.refundLedger.findMany.mockResolvedValue([]); + + jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ + asset, + balance: actualBalance, + }); + + prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); + + const alertSpy = jest.spyOn(service as any, "alertDiscrepancy").mockResolvedValue(undefined); + + const result = await service.reconcileAsset(asset, date); + + expect(result).toMatchObject({ + asset: "native", + discrepancy: "50000000", + hasUnexplainedDiscrepancy: true, + }); + + expect(alertSpy).toHaveBeenCalled(); + }); + + it("should handle negative discrepancy (actual < expected)", async () => { + const asset = "native"; + const date = new Date("2026-09-28"); + const expectedBalance = "100000000"; // 10 XLM + const actualBalance = "50000000"; // 5 XLM (5 XLM lower) + + prisma.feeLedger.findMany.mockResolvedValue([ + { + id: 1n, + intentId: "i1", + asset, + amount: expectedBalance, + accrualAt: date, + txHash: null, + }, + ] as any); + prisma.slashLedger.findMany.mockResolvedValue([]); + prisma.refundLedger.findMany.mockResolvedValue([]); + + jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ + asset, + balance: actualBalance, + }); + + prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); + + const result = await service.reconcileAsset(asset, date); + + expect(result).toMatchObject({ + asset: "native", + discrepancy: "-50000000", + hasUnexplainedDiscrepancy: true, + }); + }); + }); + + describe("getReconciliationSummary", () => { + it("should return reconciliation summary for a date", async () => { + const snapshotDate = "2026-09-28"; + const mockSnapshots = [ + { + id: 1n, + snapshotDate, + asset: "native", + expectedBalance: "100000000", + actualBalance: "100000000", + discrepancy: "0", + toleranceThreshold: "10000000", + hasUnexplainedDiscrepancy: false, + explanation: "Balances match exactly.", + createdAt: new Date("2026-09-28T00:00:00Z"), + breakdown: { + fees: "100000000", + slashes: "0", + refunds: "0", + }, + }, + { + id: 2n, + snapshotDate, + asset: "USDC", + expectedBalance: "50000000", + actualBalance: "52000000", + discrepancy: "2000000", + toleranceThreshold: "1000000", + hasUnexplainedDiscrepancy: true, + explanation: "Exceeds tolerance.", + createdAt: new Date("2026-09-28T00:00:00Z"), + breakdown: { + fees: "50000000", + slashes: "0", + refunds: "0", + }, + }, + ]; + + prisma.treasurySnapshot.findMany.mockResolvedValue(mockSnapshots as any); + + const summary = await service.getReconciliationSummary(snapshotDate); + + expect(summary).toMatchObject({ + date: snapshotDate, + totalDiscrepancies: 1, + assetsWithUnexplainedDiscrepancies: 1, + }); + + expect(summary.assets).toHaveLength(2); + expect(summary.assets[0].asset).toBe("native"); + expect(summary.assets[1].asset).toBe("USDC"); + }); + }); + + describe("getReconciliationDetail", () => { + it("should return detailed reconciliation for an asset", async () => { + const snapshotDate = "2026-09-28"; + const asset = "native"; + + const mockSnapshot = { + id: 1n, + snapshotDate, + asset, + expectedBalance: "100000000", + actualBalance: "100500000", + discrepancy: "500000", + toleranceThreshold: "10000000", + hasUnexplainedDiscrepancy: false, + explanation: "Within tolerance.", + createdAt: new Date("2026-09-28T00:00:00Z"), + breakdown: { + fees: "100000000", + slashes: "0", + refunds: "0", + }, + }; + + prisma.treasurySnapshot.findUnique.mockResolvedValue(mockSnapshot as any); + + const mockFees = [ + { + id: 1n, + intentId: "i1", + asset, + amount: "50000000", + accrualAt: new Date("2026-09-27T12:00:00Z"), + txHash: "tx1", + }, + ]; + + const mockSlashes = [ + { + id: 1n, + solverAddress: "s1", + asset, + amount: "10000000", + slashedAt: new Date("2026-09-27T13:00:00Z"), + reason: "Timeout", + txHash: "tx2", + }, + ]; + + const mockRefunds = [ + { + id: 1n, + intentId: "i2", + userAddress: "u1", + asset, + amount: "5000000", + issuedAt: new Date("2026-09-27T14:00:00Z"), + reason: "Failed tx", + txHash: "tx3", + }, + ]; + + prisma.feeLedger.findMany.mockResolvedValue(mockFees as any); + prisma.slashLedger.findMany.mockResolvedValue(mockSlashes as any); + prisma.refundLedger.findMany.mockResolvedValue(mockRefunds as any); + + const detail = await service.getReconciliationDetail(asset, snapshotDate); + + expect(detail).toMatchObject({ + snapshotDate, + asset, + expectedBalance: "100000000", + actualBalance: "100500000", + discrepancy: "500000", + }); + + expect(detail.recentTransactions).toHaveLength(3); + expect(detail.recentTransactions[0].type).toBe("refund"); + expect(detail.recentTransactions[1].type).toBe("slash"); + expect(detail.recentTransactions[2].type).toBe("fee"); + }); + + it("should throw error if snapshot not found", async () => { + prisma.treasurySnapshot.findUnique.mockResolvedValue(null); + + await expect( + service.getReconciliationDetail("nonexistent", "2026-09-28"), + ).rejects.toThrow("No reconciliation found"); + }); + }); +}); diff --git a/src/treasury/treasury.service.ts b/src/treasury/treasury.service.ts index e69de29b..09b543d4 100644 --- a/src/treasury/treasury.service.ts +++ b/src/treasury/treasury.service.ts @@ -0,0 +1,534 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { PrismaService } from "../prisma/prisma.service"; +import { SorobanService } from "../soroban/soroban.service"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + AssetBalance, + ExpectedBalance, + ReconciliationResult, + ReconciliationSummary, + ReconciliationDetailResponse, + FeeLedgerEntry, + SlashLedgerEntry, + RefundLedgerEntry, +} from "./treasury.types"; +import { AppConfig } from "../config/configuration"; + +/** + * TreasuryService + * + * Aggregates fee-ledger accruals, slash proceeds, and refunds, + * and reconciles them daily against actual on-chain treasury balances. + */ +@Injectable() +export class TreasuryService { + private readonly logger = new Logger(TreasuryService.name); + private readonly horizonServer: StellarSdk.Horizon.Server; + private readonly treasuryAddress: string; + private readonly toleranceThresholds: Map; + + constructor( + private readonly prisma: PrismaService, + private readonly soroban: SorobanService, + private readonly configService: ConfigService, + ) { + const horizonUrl = this.configService.get("stellar.horizonUrl", { infer: true }); + this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + + this.treasuryAddress = this.configService.get("treasury.address", { infer: true }); + + // Default tolerance thresholds per asset (in base units) + // Could be moved to config/database + this.toleranceThresholds = new Map([ + ["native", 10000000n], // 1 XLM (7 decimals) + ["USDC", 1000000n], // 1 USDC (6 decimals) + ]); + } + + /** + * Record a fee accrual in the ledger + */ + async recordFee(entry: FeeLedgerEntry): Promise { + await this.prisma.feeLedger.create({ + data: { + intentId: entry.intentId, + asset: entry.asset, + amount: entry.amount, + accrualAt: entry.accrualAt, + txHash: entry.txHash, + }, + }); + + this.logger.log(`Recorded fee: ${entry.amount} ${entry.asset} for intent ${entry.intentId}`); + } + + /** + * Record a slash in the ledger + */ + async recordSlash(entry: SlashLedgerEntry): Promise { + await this.prisma.slashLedger.create({ + data: { + solverAddress: entry.solverAddress, + asset: entry.asset, + amount: entry.amount, + slashedAt: entry.slashedAt, + reason: entry.reason, + txHash: entry.txHash, + }, + }); + + this.logger.log(`Recorded slash: ${entry.amount} ${entry.asset} from solver ${entry.solverAddress}`); + } + + /** + * Record a refund in the ledger + */ + async recordRefund(entry: RefundLedgerEntry): Promise { + await this.prisma.refundLedger.create({ + data: { + intentId: entry.intentId, + userAddress: entry.userAddress, + asset: entry.asset, + amount: entry.amount, + issuedAt: entry.issuedAt, + reason: entry.reason, + txHash: entry.txHash, + }, + }); + + this.logger.log(`Recorded refund: ${entry.amount} ${entry.asset} to user ${entry.userAddress}`); + } + + /** + * Calculate expected treasury balance from ledgers + */ + async calculateExpectedBalance(asset: string, untilDate?: Date): Promise { + const until = untilDate || new Date(); + + // Aggregate fees + const fees = await this.prisma.feeLedger.findMany({ + where: { + asset, + accrualAt: { lte: until }, + }, + }); + const totalFees = fees.reduce((sum, f) => sum + BigInt(f.amount), 0n); + + // Aggregate slashes + const slashes = await this.prisma.slashLedger.findMany({ + where: { + asset, + slashedAt: { lte: until }, + }, + }); + const totalSlashes = slashes.reduce((sum, s) => sum + BigInt(s.amount), 0n); + + // Aggregate refunds + const refunds = await this.prisma.refundLedger.findMany({ + where: { + asset, + issuedAt: { lte: until }, + }, + }); + const totalRefunds = refunds.reduce((sum, r) => sum + BigInt(r.amount), 0n); + + const netExpected = totalFees + totalSlashes - totalRefunds; + + return { + asset, + totalFees: totalFees.toString(), + totalSlashes: totalSlashes.toString(), + totalRefunds: totalRefunds.toString(), + netExpected: netExpected.toString(), + }; + } + + /** + * Fetch actual on-chain balance for treasury account + */ + async fetchActualBalance(asset: string): Promise { + try { + const account = await this.horizonServer.loadAccount(this.treasuryAddress); + + // Handle native XLM + if (asset === "native") { + const balance = account.balances.find((b) => b.asset_type === "native"); + return { + asset: "native", + balance: balance ? this.parseBalance(balance.balance) : "0", + }; + } + + // Handle issued assets (traditional Stellar assets) + const [code, issuer] = asset.split(":"); + if (issuer) { + const balance = account.balances.find( + (b): b is typeof b & { asset_code: string; asset_issuer: string } => + b.asset_type !== "native" && + "asset_code" in b && + b.asset_code === code && + b.asset_issuer === issuer, + ); + return { + asset, + balance: balance ? this.parseBalance(balance.balance) : "0", + }; + } + + // Handle Soroban tokens (SAC balances) + // This would require calling a Soroban contract method + // For now, return placeholder - implement based on your contract structure + this.logger.warn(`Soroban asset balance fetch not yet implemented for ${asset}`); + return { + asset, + balance: "0", + contract: asset, + }; + } catch (error) { + this.logger.error(`Failed to fetch balance for ${asset}:`, error); + throw error; + } + } + + /** + * Parse Horizon balance string to base units (stroops) + */ + private parseBalance(balance: string): string { + // Horizon returns balances as decimal strings like "100.0000000" + // Convert to stroops (1 XLM = 10^7 stroops) + const [whole, decimal = ""] = balance.split("."); + const paddedDecimal = decimal.padEnd(7, "0"); + return (BigInt(whole) * 10000000n + BigInt(paddedDecimal)).toString(); + } + + /** + * Perform reconciliation for a single asset + */ + async reconcileAsset( + asset: string, + date: Date = new Date(), + ): Promise { + const snapshotDate = date.toISOString().split("T")[0]; + + this.logger.log(`Reconciling asset ${asset} for date ${snapshotDate}`); + + // Calculate expected balance from ledgers + const expected = await this.calculateExpectedBalance(asset, date); + + // Fetch actual on-chain balance + const actual = await this.fetchActualBalance(asset); + + const expectedBigInt = BigInt(expected.netExpected); + const actualBigInt = BigInt(actual.balance); + const discrepancy = actualBigInt - expectedBigInt; + const absDiscrepancy = discrepancy < 0n ? -discrepancy : discrepancy; + + const tolerance = this.toleranceThresholds.get(asset) || 0n; + const hasUnexplainedDiscrepancy = absDiscrepancy > tolerance; + + // Calculate percentage + const discrepancyPercentage = expectedBigInt > 0n + ? Number((discrepancy * 10000n) / expectedBigInt) / 100 + : 0; + + // Generate explanation + const explanation = this.generateExplanation( + discrepancy, + hasUnexplainedDiscrepancy, + asset, + ); + + const result: ReconciliationResult = { + snapshotDate, + asset, + expectedBalance: expected.netExpected, + actualBalance: actual.balance, + discrepancy: discrepancy.toString(), + discrepancyPercentage, + toleranceThreshold: tolerance.toString(), + hasUnexplainedDiscrepancy, + explanation, + breakdown: { + fees: expected.totalFees, + slashes: expected.totalSlashes, + refunds: expected.totalRefunds, + }, + }; + + // Save snapshot to database + await this.prisma.treasurySnapshot.upsert({ + where: { + snapshot_date_asset_unique: { + snapshotDate, + asset, + }, + }, + create: { + snapshotDate, + asset, + expectedBalance: expected.netExpected, + actualBalance: actual.balance, + discrepancy: discrepancy.toString(), + toleranceThreshold: tolerance.toString(), + hasUnexplainedDiscrepancy, + explanation, + breakdown: result.breakdown, + }, + update: { + expectedBalance: expected.netExpected, + actualBalance: actual.balance, + discrepancy: discrepancy.toString(), + hasUnexplainedDiscrepancy, + explanation, + breakdown: result.breakdown, + }, + }); + + // Alert on unexplained discrepancies + if (hasUnexplainedDiscrepancy) { + await this.alertDiscrepancy(result); + } + + return result; + } + + /** + * Generate human-readable explanation for discrepancies + */ + private generateExplanation( + discrepancy: bigint, + hasUnexplainedDiscrepancy: boolean, + asset: string, + ): string | null { + if (discrepancy === 0n) { + return "Balances match exactly."; + } + + if (!hasUnexplainedDiscrepancy) { + return `Discrepancy within tolerance threshold. Likely due to in-flight settlements or pending transactions.`; + } + + const direction = discrepancy > 0n ? "higher" : "lower"; + return `Treasury balance is ${direction} than expected by ${discrepancy.toString()} base units. This exceeds the tolerance threshold and requires investigation.`; + } + + /** + * Perform daily reconciliation for all tracked assets + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async performDailyReconciliation(): Promise { + this.logger.log("Starting daily treasury reconciliation"); + + try { + // Get all unique assets from ledgers + const assetsFromFees = await this.prisma.feeLedger.findMany({ + select: { asset: true }, + distinct: ["asset"], + }); + + const assetsFromSlashes = await this.prisma.slashLedger.findMany({ + select: { asset: true }, + distinct: ["asset"], + }); + + const allAssets = new Set([ + ...assetsFromFees.map((f) => f.asset), + ...assetsFromSlashes.map((s) => s.asset), + ]); + + const results: ReconciliationResult[] = []; + + for (const asset of allAssets) { + try { + const result = await this.reconcileAsset(asset); + results.push(result); + } catch (error) { + this.logger.error(`Failed to reconcile asset ${asset}:`, error); + } + } + + const withDiscrepancies = results.filter((r) => r.hasUnexplainedDiscrepancy); + + this.logger.log( + `Daily reconciliation complete. ${results.length} assets checked, ` + + `${withDiscrepancies.length} with unexplained discrepancies.`, + ); + } catch (error) { + this.logger.error("Daily reconciliation failed:", error); + throw error; + } + } + + /** + * Get reconciliation summary for a specific date + */ + async getReconciliationSummary(date?: string): Promise { + const snapshotDate = date || new Date().toISOString().split("T")[0]; + + const snapshots = await this.prisma.treasurySnapshot.findMany({ + where: { snapshotDate }, + orderBy: { asset: "asc" }, + }); + + const assets: ReconciliationResult[] = snapshots.map((s) => ({ + snapshotDate: s.snapshotDate, + asset: s.asset, + expectedBalance: s.expectedBalance, + actualBalance: s.actualBalance, + discrepancy: s.discrepancy, + discrepancyPercentage: this.calculatePercentage( + BigInt(s.discrepancy), + BigInt(s.expectedBalance), + ), + toleranceThreshold: s.toleranceThreshold, + hasUnexplainedDiscrepancy: s.hasUnexplainedDiscrepancy, + explanation: s.explanation, + breakdown: s.breakdown as any, + })); + + return { + date: snapshotDate, + assets, + totalDiscrepancies: assets.filter((a) => BigInt(a.discrepancy) !== 0n).length, + assetsWithUnexplainedDiscrepancies: assets.filter( + (a) => a.hasUnexplainedDiscrepancy, + ).length, + lastReconciliationAt: snapshots[0]?.createdAt.toISOString() || new Date().toISOString(), + }; + } + + /** + * Get detailed reconciliation for a specific asset + */ + async getReconciliationDetail( + asset: string, + date?: string, + ): Promise { + const snapshotDate = date || new Date().toISOString().split("T")[0]; + + const snapshot = await this.prisma.treasurySnapshot.findUnique({ + where: { + snapshot_date_asset_unique: { + snapshotDate, + asset, + }, + }, + }); + + if (!snapshot) { + throw new Error(`No reconciliation found for asset ${asset} on ${snapshotDate}`); + } + + // Fetch recent transactions (last 100 of each type) + const [fees, slashes, refunds] = await Promise.all([ + this.prisma.feeLedger.findMany({ + where: { asset }, + orderBy: { accrualAt: "desc" }, + take: 100, + }), + this.prisma.slashLedger.findMany({ + where: { asset }, + orderBy: { slashedAt: "desc" }, + take: 100, + }), + this.prisma.refundLedger.findMany({ + where: { asset }, + orderBy: { issuedAt: "desc" }, + take: 100, + }), + ]); + + const recentTransactions = [ + ...fees.map((f) => ({ + type: "fee" as const, + amount: f.amount, + timestamp: f.accrualAt.toISOString(), + reference: f.intentId, + })), + ...slashes.map((s) => ({ + type: "slash" as const, + amount: s.amount, + timestamp: s.slashedAt.toISOString(), + reference: s.solverAddress, + })), + ...refunds.map((r) => ({ + type: "refund" as const, + amount: r.amount, + timestamp: r.issuedAt.toISOString(), + reference: r.intentId, + })), + ].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + return { + snapshotDate: snapshot.snapshotDate, + asset: snapshot.asset, + expectedBalance: snapshot.expectedBalance, + actualBalance: snapshot.actualBalance, + discrepancy: snapshot.discrepancy, + discrepancyPercentage: this.calculatePercentage( + BigInt(snapshot.discrepancy), + BigInt(snapshot.expectedBalance), + ), + toleranceThreshold: snapshot.toleranceThreshold, + hasUnexplainedDiscrepancy: snapshot.hasUnexplainedDiscrepancy, + explanation: snapshot.explanation, + breakdown: snapshot.breakdown as any, + recentTransactions, + }; + } + + /** + * Calculate percentage from bigints + */ + private calculatePercentage(discrepancy: bigint, expected: bigint): number { + if (expected === 0n) return 0; + return Number((discrepancy * 10000n) / expected) / 100; + } + + /** + * Alert on unexplained discrepancies + */ + private async alertDiscrepancy(result: ReconciliationResult): Promise { + const severity = this.getSeverity(result); + + this.logger.warn( + `[${severity.toUpperCase()}] Treasury discrepancy detected for ${result.asset}: ` + + `${result.discrepancy} base units (${result.discrepancyPercentage.toFixed(2)}%)`, + ); + + // TODO: Integrate with alerting system (PagerDuty, Slack, etc.) + // For now, just log the alert + } + + /** + * Determine severity of discrepancy + */ + private getSeverity(result: ReconciliationResult): "warning" | "critical" { + const absPercentage = Math.abs(result.discrepancyPercentage); + + // Critical if discrepancy > 5% + if (absPercentage > 5) { + return "critical"; + } + + return "warning"; + } + + /** + * Manual reconciliation trigger (admin use) + */ + async triggerReconciliation(asset?: string): Promise { + if (asset) { + const result = await this.reconcileAsset(asset); + return [result]; + } + + // Reconcile all assets + await this.performDailyReconciliation(); + + const summary = await this.getReconciliationSummary(); + return summary.assets; + } +} diff --git a/test/__mocks__/nestjs-event-emitter.ts b/test/__mocks__/nestjs-event-emitter.ts new file mode 100644 index 00000000..77796ee7 --- /dev/null +++ b/test/__mocks__/nestjs-event-emitter.ts @@ -0,0 +1,9 @@ +/** + * Jest stand-in for the ESM-only @nestjs/event-emitter package (same pattern + * as the @nestjs/schedule mock above it: jest's CJS transform cannot parse + * the package's `export` syntax). The codebase only touches `emit`, which + * Node's EventEmitter already provides. + */ +import { EventEmitter } from "events"; + +export class EventEmitter2 extends EventEmitter {} diff --git a/tools/simulator/archive.spec.ts b/tools/simulator/archive.spec.ts new file mode 100644 index 00000000..2b07b1b1 --- /dev/null +++ b/tools/simulator/archive.spec.ts @@ -0,0 +1,119 @@ +/** + * Data-layer tests for the simulation harness (issue #452): archive + * parsing, the synthetic generator, price lookups, and the seeded PRNG. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; + +const FIXTURE_DIR = join(__dirname, "fixtures"); + +function fixture(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), "utf8"); +} + +describe("parseIntentEvents (issue #452)", () => { + it("parses the sample fixture, preserving order and dispatch kinds", () => { + const events = parseIntentEvents(fixture("sample-intents.jsonl")); + expect(events).toHaveLength(6); + for (let i = 1; i < events.length; i += 1) { + expect(events[i].ts).toBeGreaterThanOrEqual(events[i - 1].ts); + } + expect(events[0].intent.intentId).toBe("sample-1"); + expect(events[5].intent.event).toBe("quote_request"); + expect(events[2].intent.auction?.decayEnd).toBe(1759248360); + expect(events[1].intent.usdValueAtCreate).toBe(1000); + }); + + it("ignores blank lines and # comments", () => { + const events = parseIntentEvents('# header\n\n{"intentId":"a","createdAt":10,"deadline":20,"srcChain":"base","srcAmount":"1","minDstAmount":"1"}\n'); + expect(events).toHaveLength(1); + }); + + it("reports the offending line for invalid JSON", () => { + expect(() => parseIntentEvents('{"intentId":"a"}\n{oops\n')).toThrow(/line 2: invalid JSON/); + }); + + it("reports the missing field for incomplete rows", () => { + expect(() => parseIntentEvents('{"intentId":"a","deadline":20}\n')).toThrow(/line 1: "createdAt" must be a finite number/); + }); +}); + +describe("generateSyntheticArchive (issue #452)", () => { + it("is deterministic for a given seed and sorted by time", () => { + const a = generateSyntheticArchive(500, 7); + const b = generateSyntheticArchive(500, 7); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + for (let i = 1; i < a.length; i += 1) { + expect(a[i].ts).toBeGreaterThanOrEqual(a[i - 1].ts); + } + expect(a[0].intent.intentId).toBe("syn-0"); + expect(a[499].intent.intentId).toBe("syn-499"); + }); + + it("rejects negative counts", () => { + expect(() => generateSyntheticArchive(-1, 0)).toThrow(/non-negative integer/); + }); +}); + +describe("PriceBook (issue #452)", () => { + const book = new PriceBook([ + { ts: 100, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 200, chain: "ethereum", symbol: "USDC", priceUsd: 1.5, decimals: 6 }, + { ts: 50, chain: "stellar", symbol: "USDC", priceUsd: 1 }, + ]); + + it("returns the latest snapshot at or before the requested time", () => { + expect(book.lookup("ethereum", "USDC", 99)).toBeNull(); + expect(book.lookup("ethereum", "USDC", 100)).toEqual({ priceUsd: 1, decimals: 6 }); + expect(book.lookup("ethereum", "USDC", 199)).toEqual({ priceUsd: 1, decimals: 6 }); + expect(book.lookup("ethereum", "USDC", 1_000)).toEqual({ priceUsd: 1.5, decimals: 6 }); + }); + + it("returns null for unknown pairs", () => { + expect(book.lookup("ethereum", "WETH", 500)).toBeNull(); + expect(new PriceBook().lookup("ethereum", "USDC", 500)).toBeNull(); + }); + + it("converts base units with per-chain default decimals", () => { + // ethereum row has explicit decimals: 6 → 2_500_000_000 = 2500. + expect(book.usdValue("ethereum", "USDC", "2500000000", 150)).toBe(2500); + // stellar row has no decimals → default 7 → 100000000 = 10. + expect(book.usdValue("stellar", "USDC", "100000000", 60)).toBe(10); + // Non-numeric amounts are unknown, never zero. + expect(book.usdValue("stellar", "USDC", "not-a-number", 60)).toBeNull(); + }); +}); + +describe("parsePriceBook (issue #452)", () => { + it("builds a working book from JSONL", () => { + const book = parsePriceBook(fixture("sample-prices.jsonl")); + expect(book.lookup("stellar", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 7 }); + expect(book.lookup("base", "USDC", 1759248000)).toEqual({ priceUsd: 1, decimals: 6 }); + }); + + it("validates required fields", () => { + expect(() => parsePriceBook('{"ts":1,"chain":"ethereum"}\n')).toThrow(/"symbol" must be a non-empty string/); + }); +}); + +describe("createPrng (issue #452)", () => { + it("reproduces the same sequence for the same seed", () => { + const a = createPrng(123); + const b = createPrng(123); + const seqA = Array.from({ length: 32 }, () => a()); + const seqB = Array.from({ length: 32 }, () => b()); + expect(seqA).toEqual(seqB); + }); + + it("stays in [0, 1)", () => { + const random = createPrng(9); + for (let i = 0; i < 1_000; i += 1) { + const value = random(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); diff --git a/tools/simulator/archive.ts b/tools/simulator/archive.ts new file mode 100644 index 00000000..e3893170 --- /dev/null +++ b/tools/simulator/archive.ts @@ -0,0 +1,139 @@ +/** + * Archive I/O for the simulation harness (issue #452). + * + * Input is JSON Lines: one archived intent event (or price snapshot) per + * line. Rows in the public `intents` dataset schema replay directly; rows + * without an explicit `event` field default to `"intent"`. `#` comment + * lines and blank lines are ignored, which keeps fixtures hand-editable. + */ +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import type { PriceRow } from "./prices"; +import type { ArchivedIntent, SimEvent } from "./types"; + +function parseJsonl(text: string, label: string): Array<{ lineNo: number; value: unknown }> { + const out: Array<{ lineNo: number; value: unknown }> = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("#")) continue; + try { + out.push({ lineNo: i + 1, value: JSON.parse(line) }); + } catch (err) { + throw new Error(`${label} line ${i + 1}: invalid JSON (${(err as Error).message})`); + } + } + return out; +} + +function requireNumber(row: Record, key: string, lineNo: number, label: string): number { + const value = row[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${label} line ${lineNo}: "${key}" must be a finite number`); + } + return value; +} + +function requireString(row: Record, key: string, lineNo: number, label: string): string { + const value = row[key]; + if (typeof value !== "string" || value === "") { + throw new Error(`${label} line ${lineNo}: "${key}" must be a non-empty string`); + } + return value; +} + +/** + * Parse an archived intent stream (JSONL) into timestamped events. + * + * @param text Raw file contents. + * @throws When a row is not valid JSON or lacks the required fields + * (`intentId`, `createdAt`, `deadline`, `srcChain`, `srcAmount`, `minDstAmount`). + * @returns Events sorted by timestamp (stable for equal timestamps). + */ +export function parseIntentEvents(text: string): SimEvent[] { + const events: SimEvent[] = []; + for (const { lineNo, value } of parseJsonl(text, "intents archive")) { + const row = value as Record; + const intent: ArchivedIntent = { + intentId: requireString(row, "intentId", lineNo, "intents archive"), + createdAt: requireNumber(row, "createdAt", lineNo, "intents archive"), + deadline: requireNumber(row, "deadline", lineNo, "intents archive"), + srcChain: requireString(row, "srcChain", lineNo, "intents archive"), + srcAmount: requireString(row, "srcAmount", lineNo, "intents archive"), + minDstAmount: requireString(row, "minDstAmount", lineNo, "intents archive"), + srcToken: typeof row.srcToken === "string" ? row.srcToken : undefined, + srcTokenSymbol: typeof row.srcTokenSymbol === "string" ? row.srcTokenSymbol : undefined, + dstToken: typeof row.dstToken === "string" ? row.dstToken : undefined, + dstTokenSymbol: typeof row.dstTokenSymbol === "string" ? row.dstTokenSymbol : undefined, + dstChain: typeof row.dstChain === "string" ? row.dstChain : undefined, + state: typeof row.state === "string" ? row.state : undefined, + usdValueAtCreate: typeof row.usdValueAtCreate === "number" ? row.usdValueAtCreate : undefined, + auction: (row.auction as ArchivedIntent["auction"]) ?? undefined, + event: row.event === "quote_request" ? "quote_request" : "intent", + }; + events.push({ ts: intent.createdAt, intent }); + } + return events.sort((a, b) => a.ts - b.ts); +} + +/** + * Parse archived price snapshots (JSONL) into a {@link PriceBook}. + * + * @throws When a row lacks `ts`/`chain`/`symbol`/`priceUsd`. + */ +export function parsePriceBook(text: string): PriceBook { + const rows: PriceRow[] = []; + for (const { lineNo, value } of parseJsonl(text, "prices archive")) { + const row = value as Record; + rows.push({ + ts: requireNumber(row, "ts", lineNo, "prices archive"), + chain: requireString(row, "chain", lineNo, "prices archive"), + symbol: requireString(row, "symbol", lineNo, "prices archive"), + priceUsd: requireNumber(row, "priceUsd", lineNo, "prices archive"), + decimals: typeof row.decimals === "number" ? row.decimals : undefined, + }); + } + return new PriceBook(rows); +} + +const SYNTHETIC_SRC_CHAINS = ["ethereum", "base", "polygon", "arbitrum", "optimism", "avalanche"]; + +/** + * Generate a deterministic synthetic archive — used by benchmarks, the + * determinism tests, and `cli.ts --generate` for smoke runs without data. + * + * The stream mixes zero-width bursts (several intents in the same second) + * with one-second gaps, so equal-timestamp ordering and clock advancement + * are both exercised. + * + * @param count Number of intents to generate. + * @param seed PRNG seed; identical inputs yield identical archives. + * @returns Events already sorted by timestamp. + */ +export function generateSyntheticArchive(count: number, seed: number): SimEvent[] { + if (!Number.isInteger(count) || count < 0) { + throw new Error(`count must be a non-negative integer, got ${count}`); + } + const random = createPrng(seed); + const events: SimEvent[] = new Array(count); + let ts = 1_700_000_000; + for (let i = 0; i < count; i += 1) { + if (random() < 0.3) ts += 1; // ~70% of events share the previous second + const intent: ArchivedIntent = { + intentId: `syn-${i}`, + createdAt: ts, + deadline: ts + 60 + Math.floor(random() * 120), + srcChain: SYNTHETIC_SRC_CHAINS[i % SYNTHETIC_SRC_CHAINS.length], + srcToken: "0xsimulated-src", + srcTokenSymbol: "TOK", + srcAmount: "1000000000000000000", + dstToken: "CSIMDST", + dstTokenSymbol: "USDC", + minDstAmount: String(1_000_000 + (i % 100) * 1_000), + state: "open", + event: "intent", + }; + events[i] = { ts, intent }; + } + return events; +} diff --git a/tools/simulator/cli.spec.ts b/tools/simulator/cli.spec.ts new file mode 100644 index 00000000..39ef21c1 --- /dev/null +++ b/tools/simulator/cli.spec.ts @@ -0,0 +1,165 @@ +/** + * CLI tests (issue #452): argument handling, fixture replay, sweep mode, + * synthetic generation, and the `--json` report dump. + */ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCli, USAGE } from "./cli"; + +const FIXTURES = join(__dirname, "fixtures"); +const INTENTS = join(FIXTURES, "sample-intents.jsonl"); +const PRICES = join(FIXTURES, "sample-prices.jsonl"); + +describe("runCli (issue #452)", () => { + it("prints usage and exits 0 for --help", () => { + const result = runCli(["--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(USAGE); + expect(result.stdout).toContain("--sweep"); + }); + + it("requires an input source", () => { + const result = runCli([]); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("--input or --generate is required"); + }); + + it("rejects unknown flags with usage", () => { + const result = runCli(["--nope"]); + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("Usage:"); + }); + + it("replays the fixture with the default margin strategy", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Strategy: margin-threshold"); + expect(result.stdout).toContain("fill rate 100.0%"); + expect(result.stdout).toContain("Slash risk:"); + expect(result.stdout).toContain("PnL:"); + expect(result.stdout).toContain("unpriced fills: 0"); + }); + + it("fills everything with the always-fill strategy", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--strategy", "always"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Strategy: always-fill"); + expect(result.stdout).toContain("6 submitted, 0 declined"); + }); + + it("declines everything above a hostile margin threshold", () => { + const result = runCli([ + "--input", + INTENTS, + "--prices", + PRICES, + "--strategy", + "margin", + "--min-margin-bps", + "1000", + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Filled: 0"); + expect(result.stdout).toContain("6 declined"); + }); + + it("rejects unknown strategies", () => { + const result = runCli(["--input", INTENTS, "--strategy", "yolo"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('unknown strategy "yolo"'); + }); + + it("runs a fill-window × fee-bps sweep as a markdown table", () => { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--sweep"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("| fill window (s) | fee bps |"); + expect(result.stdout).toContain("| 60 | 0 |"); + expect(result.stdout).toContain("| 300 | 10 |"); + // Default grid: 3 windows × 3 fee levels. + const dataRows = result.stdout.split("\n").filter((line) => /^\| \d/.test(line)); + expect(dataRows).toHaveLength(9); + }); + + it("honours explicit sweep axes", () => { + const result = runCli([ + "--input", + INTENTS, + "--sweep", + "--fill-windows", + "0,60", + "--fee-bps-list", + "5", + ]); + expect(result.exitCode).toBe(0); + const dataRows = result.stdout.split("\n").filter((line) => /^\| \d/.test(line)); + expect(dataRows).toHaveLength(2); + }); + + it("rejects a malformed sweep axis", () => { + const result = runCli(["--input", INTENTS, "--sweep", "--fill-windows", "abc"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("--fill-windows: not a number: abc"); + }); + + it("generates a synthetic archive with --generate", () => { + const result = runCli(["--generate", "500", "--strategy", "always"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Events: 500"); + expect(result.stdout).toContain("fill rate 100.0%"); + }); + + it("validates --generate input", () => { + // "--generate -5" is rejected by parseArgs as ambiguous; the "=" form + // reaches our own non-negative-integer validation. + const result = runCli(["--generate=-5"]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("--generate needs a non-negative integer"); + }); + + it("reports missing input files", () => { + const result = runCli(["--input", join(FIXTURES, "does-not-exist.jsonl")]); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("ENOENT"); + }); + + it("writes the report JSON when --json is given", () => { + const dir = mkdtempSync(join(tmpdir(), "sim-harness-")); + const outPath = join(dir, "report.json"); + try { + const result = runCli(["--input", INTENTS, "--prices", PRICES, "--json", outPath]); + expect(result.exitCode).toBe(0); + expect(result.jsonPath).toBe(outPath); + expect(result.stdout).toContain(`report JSON written to ${outPath}`); + const report = JSON.parse(readFileSync(outPath, "utf8")); + expect(report.strategy).toBe("margin-threshold"); + expect(report.totals.filled).toBe(6); + expect(report.params.seed).toBe(42); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes sweep JSON as a comparative report", () => { + const dir = mkdtempSync(join(tmpdir(), "sim-harness-")); + const outPath = join(dir, "sweep.json"); + try { + const result = runCli([ + "--input", + INTENTS, + "--prices", + PRICES, + "--sweep", + "--json", + outPath, + ]); + expect(result.exitCode).toBe(0); + const sweep = JSON.parse(readFileSync(outPath, "utf8")); + expect(sweep.rows).toHaveLength(9); + expect(sweep.strategy).toBe("margin-threshold"); + expect(sweep.seed).toBe(42); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/simulator/cli.ts b/tools/simulator/cli.ts new file mode 100644 index 00000000..a3acc861 --- /dev/null +++ b/tools/simulator/cli.ts @@ -0,0 +1,224 @@ +/** + * CLI for the solver simulation harness (issue #452). + * + * tsx tools/simulator/cli.ts --input archive.jsonl --strategy margin + * tsx tools/simulator/cli.ts --input archive.jsonl --sweep \ + * --fill-windows 60,120,300 --fee-bps-list 0,5,10 + * tsx tools/simulator/cli.ts --generate 100000 --strategy always + * + * Node's built-in `parseArgs` keeps the tool dependency-free. Nothing + * here touches the network or the wall clock; reports are pure functions + * of (archive, prices, strategy, params, seed). + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; +import { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +import { ReplayEngine } from "./engine"; +import { formatReport, formatSweep } from "./report"; +import { runSweep } from "./sweep"; +import type { SweepGrid } from "./sweep"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { SimEvent, SimParams, SimulatorStrategy } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Usage text printed for `--help` and on argument errors. */ +export const USAGE = [ + "Usage: tsx tools/simulator/cli.ts --input [options]", + "", + "Inputs", + " --input archived intent events (JSONL, required unless --generate)", + " --prices archived price snapshots (JSONL)", + " --generate build a synthetic archive of n intents instead", + "", + "Strategy & params", + " --strategy always | margin (default: margin)", + " --min-margin-bps margin threshold (default: 0)", + " --auction let the margin strategy wait for Dutch-auction decay", + " --seed PRNG seed (default: 42)", + " --fee-bps protocol fee bps (default: 5)", + " --fill-window fill window from creation (default: deadline only)", + " --chains chain allowlist (default: all supported chains)", + " --slash-usd USD penalty per unfilled acceptance (default: 100)", + " --gas-usd USD cost per fill (default: 0)", + "", + "Sweep mode", + " --sweep replay across the parameter grid below", + " --fill-windows fill-window axis (default: 60,120,300)", + " --fee-bps-list fee-bps axis (default: 0,5,10)", + "", + "Output", + " --json also write the raw report JSON to a file", + " --help show this help", +].join("\n"); + +/** Outcome of a CLI invocation — pure data so tests can assert on it. */ +export interface CliResult { + stdout: string; + exitCode: number; + /** Destination of the `--json` dump, when requested. */ + jsonPath?: string; +} + +/** Loose view of `parseArgs` values (string options + booleans). */ +type CliValues = Record; + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: string | boolean | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed)) throw new Error(`not a number: ${String(value)}`); + return parsed; +} + +function numberList(raw: string, flag: string): number[] { + const parts = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); + if (parts.length === 0) throw new Error(`${flag} needs at least one number`); + return parts.map((part) => { + const value = Number(part); + if (!Number.isFinite(value)) throw new Error(`${flag}: not a number: ${part}`); + return value; + }); +} + +function buildStrategy(name: string, params: SimParams, useAuction: boolean): SimulatorStrategy { + if (name === "always") return new AlwaysFillStrategy({ chains: params.chains }); + if (name === "margin") { + return new MarginThresholdStrategy({ + minMarginBps: params.minMarginBps, + chains: params.chains, + useAuction, + }); + } + throw new Error(`unknown strategy "${name}" (expected: always | margin)`); +} + +/** + * Run the CLI against an argv slice (without `node`/`script`). + * + * Never throws: argument, input and runtime errors become a message on + * stdout with a non-zero exit code, so shells and tests can assert on the + * result uniformly. + */ +export function runCli(argv: readonly string[]): CliResult { + let values: CliValues; + try { + const parsed = parseArgs({ + args: [...argv], + strict: true, + options: { + input: { type: "string" }, + prices: { type: "string" }, + generate: { type: "string" }, + strategy: { type: "string" }, + "min-margin-bps": { type: "string" }, + auction: { type: "boolean" }, + seed: { type: "string" }, + "fee-bps": { type: "string" }, + "fill-window": { type: "string" }, + chains: { type: "string" }, + "slash-usd": { type: "string" }, + "gas-usd": { type: "string" }, + sweep: { type: "boolean" }, + "fill-windows": { type: "string" }, + "fee-bps-list": { type: "string" }, + json: { type: "string" }, + help: { type: "boolean" }, + }, + }); + values = parsed.values as unknown as CliValues; + } catch (err) { + return { stdout: `${(err as Error).message}\n\n${USAGE}`, exitCode: 2 }; + } + + if (values.help === true) return { stdout: USAGE, exitCode: 0 }; + + try { + const inputPath = asString(values.input); + const pricesPath = asString(values.prices); + const generate = asString(values.generate); + const jsonPath = asString(values.json); + const chainsRaw = asString(values.chains); + const fillWindowsRaw = asString(values["fill-windows"]); + const feeListRaw = asString(values["fee-bps-list"]); + const strategyName = asString(values.strategy) ?? "margin"; + + const params: SimParams = { + ...DEFAULT_SIM_PARAMS, + seed: asNumber(values.seed, DEFAULT_SIM_PARAMS.seed), + feeBps: asNumber(values["fee-bps"], DEFAULT_SIM_PARAMS.feeBps), + fillWindowSec: asNumber(values["fill-window"], DEFAULT_SIM_PARAMS.fillWindowSec), + minMarginBps: asNumber(values["min-margin-bps"], DEFAULT_SIM_PARAMS.minMarginBps), + slashPenaltyUsd: asNumber(values["slash-usd"], DEFAULT_SIM_PARAMS.slashPenaltyUsd), + gasUsdPerFill: asNumber(values["gas-usd"], DEFAULT_SIM_PARAMS.gasUsdPerFill), + chains: + chainsRaw === undefined + ? DEFAULT_SIM_PARAMS.chains + : chainsRaw + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""), + }; + + let events: SimEvent[]; + if (generate !== undefined) { + const count = Number(generate); + if (!Number.isInteger(count) || count < 0) { + throw new Error("--generate needs a non-negative integer"); + } + events = generateSyntheticArchive(count, params.seed); + } else if (inputPath !== undefined) { + events = parseIntentEvents(readFileSync(inputPath, "utf8")); + } else { + return { stdout: `--input or --generate is required\n\n${USAGE}`, exitCode: 2 }; + } + + const prices = pricesPath === undefined ? undefined : parsePriceBook(readFileSync(pricesPath, "utf8")); + const useAuction = values.auction === true; + + let stdout: string; + let jsonValue: unknown; + if (values.sweep === true) { + const grid: SweepGrid = { + fillWindowSecs: fillWindowsRaw === undefined ? [60, 120, 300] : numberList(fillWindowsRaw, "--fill-windows"), + feeBps: feeListRaw === undefined ? [0, 5, 10] : numberList(feeListRaw, "--fee-bps-list"), + }; + const sweep = runSweep(events, { + strategyFactory: (cellParams) => buildStrategy(strategyName, cellParams, useAuction), + params, + prices, + grid, + }); + stdout = formatSweep(sweep); + jsonValue = sweep; + } else { + const strategy = buildStrategy(strategyName, params, useAuction); + const report = new ReplayEngine({ strategy, params, prices }).run(events); + stdout = formatReport(report); + jsonValue = report; + } + + const result: CliResult = { stdout, exitCode: 0 }; + if (jsonPath !== undefined) { + writeFileSync(jsonPath, `${JSON.stringify(jsonValue, null, 2)}\n`, "utf8"); + result.jsonPath = jsonPath; + result.stdout += `\n\nreport JSON written to ${jsonPath}`; + } + return result; + } catch (err) { + return { stdout: `${(err as Error).message}\n\n${USAGE}`, exitCode: 1 }; + } +} + +/* istanbul ignore next -- process bootstrap, exercised by CLI runs */ +if (require.main === module) { + const result = runCli(process.argv.slice(2)); + process.stdout.write(`${result.stdout}\n`); + process.exitCode = result.exitCode; +} diff --git a/tools/simulator/engine.spec.ts b/tools/simulator/engine.spec.ts new file mode 100644 index 00000000..9979029d --- /dev/null +++ b/tools/simulator/engine.spec.ts @@ -0,0 +1,244 @@ +/** + * Replay-engine tests (issue #452): dispatch, fills, fees/PnL, failures + * and slashes, the simulated clock, and seed determinism. + */ +import { PriceBook } from "./prices"; +import { ReplayEngine } from "./engine"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimulatorStrategy, + StrategyContext, +} from "./types"; + +/** Priced world: 1 USDC on both legs (EVM 6 decimals, Stellar 7). */ +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "e-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", // 1000 USDC + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", // 996 USDC + state: "open", + ...over, + }; +} + +function event(ts: number, over: Partial = {}): SimEvent { + return { ts, intent: makeIntent({ createdAt: ts, ...over }) }; +} + +/** Strategy driven by a decision function, recording every hook call. */ +function recordingStrategy( + decide: (ctx: StrategyContext, intent: ArchivedIntent) => QuoteDecision | null, +): SimulatorStrategy & { calls: string[] } { + const calls: string[] = []; + return { + name: "recording", + calls, + onIntent(ctx, intent) { + calls.push(`intent:${intent.intentId}@${ctx.nowSec}`); + return decide(ctx, intent); + }, + onQuoteRequest(ctx, intent) { + calls.push(`quote:${intent.intentId}@${ctx.nowSec}`); + return decide(ctx, intent); + }, + onTick(_ctx, nowSec) { + calls.push(`tick@${nowSec}`); + }, + }; +} + +describe("ReplayEngine (issue #452)", () => { + it("fills every intent through the always-fill strategy and prices PnL via the fee engine", () => { + const events = [event(1_000), event(1_010, { intentId: "e-2" }), event(1_020, { intentId: "e-3" })]; + const report = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { seed: 1, gasUsdPerFill: 0.1 }, + prices: makePrices(), + }).run(events); + + // Per fill: 1000 − 996 − fee(0.498) − gas(0.1) = 3.402. + expect(report.totals.events).toBe(3); + expect(report.totals.quotesSubmitted).toBe(3); + expect(report.totals.filled).toBe(3); + expect(report.totals.fillRate).toBe(1); + expect(report.totals.captureRate).toBe(1); + expect(report.totals.volumeUsd).toBeCloseTo(3_000, 9); + expect(report.totals.feesPaidUsd).toBeCloseTo(3 * 0.498, 9); + expect(report.totals.gasPaidUsd).toBeCloseTo(0.3, 9); + expect(report.totals.pnlUsd).toBeCloseTo(3 * 3.402, 9); + expect(report.totals.slashEvents).toBe(0); + expect(report.totals.unknownPriceFills).toBe(0); + expect(report.strategy).toBe("always-fill"); + expect(report.params.seed).toBe(1); + }); + + it("applies the configured fee bps to every fill", () => { + const events = [event(1_000)]; + const free = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { feeBps: 0 }, + prices: makePrices(), + }).run(events); + const expensive = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { feeBps: 100 }, + prices: makePrices(), + }).run(events); + expect(free.totals.feesPaidUsd).toBe(0); + expect(free.totals.pnlUsd).toBeCloseTo(4, 9); // 1000 − 996 + expect(expensive.totals.feesPaidUsd).toBeCloseTo(9.96, 9); // 1% of 996 + expect(expensive.totals.pnlUsd).toBeCloseTo(-5.96, 9); + expect(expensive.totals.pnlUsd).toBeLessThan(free.totals.pnlUsd); + }); + + it("declines below the margin threshold and fills above it", () => { + const events = [event(1_000)]; + const pass = new ReplayEngine({ + strategy: new MarginThresholdStrategy(), + params: { minMarginBps: 35 }, + prices: makePrices(), + }).run(events); + const fail = new ReplayEngine({ + strategy: new MarginThresholdStrategy(), + params: { minMarginBps: 36 }, + prices: makePrices(), + }).run(events); + expect(pass.totals.filled).toBe(1); + expect(fail.totals.filled).toBe(0); + expect(fail.totals.quotesDeclined).toBe(1); + }); + + it("charges a below-minimum quote as a failed fill and a slash", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "1" })); + const report = new ReplayEngine({ strategy, params: { slashPenaltyUsd: 25 } }).run([event(1_000)]); + expect(report.totals.quotesSubmitted).toBe(1); + expect(report.totals.filled).toBe(0); + expect(report.totals.failedFills).toBe(1); + expect(report.totals.failedBelowMin).toBe(1); + expect(report.totals.slashEvents).toBe(1); + expect(report.totals.slashPenaltyUsd).toBe(25); + }); + + it("fails and slashes fills scheduled past the intent deadline", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 601_000 })); + const report = new ReplayEngine({ strategy }).run([event(1_000)]); + expect(report.totals.filled).toBe(0); + expect(report.totals.failedLate).toBe(1); + expect(report.totals.slashEvents).toBe(1); + }); + + it("tightens lateness with the fill-window protocol parameter", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 200_000 })); + const events = [event(1_000)]; // deadline 1600 → delay 200 s fits the deadline + const noWindow = new ReplayEngine({ strategy, params: { fillWindowSec: 0 } }).run(events); + expect(noWindow.totals.filled).toBe(1); + const windowed = new ReplayEngine({ strategy, params: { fillWindowSec: 100 } }).run(events); + expect(windowed.totals.filled).toBe(0); + expect(windowed.totals.failedLate).toBe(1); + expect(windowed.totals.slashEvents).toBe(1); + }); + + it("dispatches intents and quote requests to their own hooks", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000" })); + const events: SimEvent[] = [ + { ts: 1_000, intent: makeIntent({ createdAt: 1_000 }) }, + { ts: 1_010, intent: makeIntent({ createdAt: 1_010, intentId: "rfq-1", event: "quote_request" }) }, + ]; + const report = new ReplayEngine({ strategy }).run(events); + expect(report.totals.intentEvents).toBe(1); + expect(report.totals.quoteRequests).toBe(1); + expect(strategy.calls.filter((c) => c.startsWith("intent:"))).toEqual(["intent:e-1@1000"]); + expect(strategy.calls.filter((c) => c.startsWith("quote:"))).toEqual(["quote:rfq-1@1010"]); + }); + + it("ticks once per clock advance, before the event dispatch", () => { + const strategy = recordingStrategy(() => null); + const events: SimEvent[] = [ + { ts: 1_000, intent: makeIntent({ createdAt: 1_000, intentId: "a" }) }, + { ts: 1_000, intent: makeIntent({ createdAt: 1_000, intentId: "b" }) }, + { ts: 1_010, intent: makeIntent({ createdAt: 1_010, intentId: "c" }) }, + { ts: 1_050, intent: makeIntent({ createdAt: 1_050, intentId: "d" }) }, + ]; + new ReplayEngine({ strategy }).run(events); + expect(strategy.calls).toEqual([ + "tick@1000", + "intent:a@1000", + "intent:b@1000", + "tick@1010", + "intent:c@1010", + "tick@1050", + "intent:d@1050", + ]); + }); + + it("counts fills with unknown prices but excludes them from USD totals", () => { + const report = new ReplayEngine({ strategy: new AlwaysFillStrategy() }).run([event(1_000)]); + expect(report.totals.filled).toBe(1); + expect(report.totals.unknownPriceFills).toBe(1); + expect(report.totals.pnlUsd).toBe(0); + expect(report.totals.volumeUsd).toBe(0); + expect(report.totals.feesPaidUsd).toBe(0); + }); + + it("prices the source leg from usdValueAtCreate when the book lacks it", () => { + const stellarOnly = new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]); + const report = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + prices: stellarOnly, + }).run([event(1_000, { usdValueAtCreate: 1_000 })]); + expect(report.totals.unknownPriceFills).toBe(0); + expect(report.totals.pnlUsd).toBeCloseTo(1_000 - 996 - 0.498, 9); + }); + + it("sorts events by time without mutating the input array", () => { + const strategy = recordingStrategy(() => null); + const events = [event(2_000, { intentId: "second" }), event(1_000, { intentId: "first" })]; + const before = JSON.stringify(events); + new ReplayEngine({ strategy }).run(events); + expect(JSON.stringify(events)).toBe(before); + expect(strategy.calls).toEqual(["tick@1000", "intent:first@1000", "tick@2000", "intent:second@2000"]); + }); + + it("produces an identical report for the same seed, and a different one otherwise", () => { + const randomStrategy: SimulatorStrategy = { + name: "random", + onIntent: (ctx, intent) => + ctx.random() < 0.5 + ? { dstAmount: intent.minDstAmount, fillDelayMs: Math.floor(ctx.random() * 600) * 1_000 } + : null, + onQuoteRequest: () => null, + onTick: () => undefined, + }; + const events = Array.from({ length: 16 }, (_, i) => event(1_000 + i, { intentId: `r-${i}` })); + + const run = (seed: number) => + JSON.stringify(new ReplayEngine({ strategy: randomStrategy, params: { seed } }).run(events)); + + expect(run(42)).toBe(run(42)); + expect(run(42)).not.toBe(run(43)); + }); + + it("computes average fill latency across settled fills", () => { + const strategy = recordingStrategy(() => ({ dstAmount: "9960000000", fillDelayMs: 30_000 })); + const report = new ReplayEngine({ strategy }).run([event(1_000), event(1_010, { intentId: "e-2" })]); + expect(report.totals.filled).toBe(2); + expect(report.totals.avgFillLatencyMs).toBe(30_000); + }); +}); diff --git a/tools/simulator/engine.ts b/tools/simulator/engine.ts new file mode 100644 index 00000000..37180ee0 --- /dev/null +++ b/tools/simulator/engine.ts @@ -0,0 +1,327 @@ +/** + * Deterministic replay engine for the solver simulation harness (issue #452). + * + * The engine walks an archived intent stream on a simulated clock — event + * timestamps only, never `Date.now()` — completing scheduled fills, + * dispatching to the strategy's `onIntent` / `onQuoteRequest` / `onTick` + * hooks, and pricing fills through the pure domain modules: + * + * - `src/fees` — protocol fee per fill (`quoteFee`) + * - `src/routing` — settlement-time feasibility (`RoutingService.buildRoute`) + * - `src/auctions` — Dutch-auction decay (used by the margin strategy) + * + * No Nest bootstrap: everything is plain construction over pure modules. + * A fill whose simulated time passes the effective deadline (`intent + * deadline`, tightened by `fillWindowSec`) becomes a failed fill and a + * slash-risk event; a quote below `minDstAmount` fails the same way. + */ +import { RoutingService } from "../../src/routing/routing.service"; +import { DEFAULT_FEE_RULE, quoteFee } from "../../src/fees/fee-engine"; +import type { FeeRule } from "../../src/fees/fee-engine"; +import type { Route, SupportedChain } from "../../src/intents/intents.types"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimParams, + SimReport, + SimulationTotals, + StrategyContext, + SimulatorStrategy, +} from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; +import { effectiveDeadline } from "./strategies/gate"; + +/** Options for one replay. */ +export interface EngineOptions { + strategy: SimulatorStrategy; + /** Partial parameters; merged over {@link DEFAULT_SIM_PARAMS}. */ + params?: Partial; + /** Archived prices; an empty book makes all price-dependent economics "unknown". */ + prices?: PriceBook; +} + +/** A scheduled fill awaiting settlement. */ +interface PendingFill { + fillAtSec: number; + quotedAtSec: number; + intent: ArchivedIntent; + dstAmount: string; +} + +/** + * Binary min-heap over `fillAtSec`, so scheduled fills settle in time + * order without rescanning the pending set on every event (keeps the + * 1M-intent budget comfortably inside 5 minutes). + */ +class FillHeap { + private readonly items: PendingFill[] = []; + + push(item: PendingFill): void { + const items = this.items; + items.push(item); + let i = items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (items[parent].fillAtSec <= items[i].fillAtSec) break; + [items[parent], items[i]] = [items[i], items[parent]]; + i = parent; + } + } + + peek(): PendingFill | undefined { + return this.items[0]; + } + + pop(): PendingFill | undefined { + const items = this.items; + if (items.length === 0) return undefined; + const top = items[0]; + const last = items.pop() as PendingFill; + if (items.length > 0) { + items[0] = last; + let i = 0; + for (;;) { + const left = 2 * i + 1; + const right = left + 1; + let smallest = i; + if (left < items.length && items[left].fillAtSec < items[smallest].fillAtSec) smallest = left; + if (right < items.length && items[right].fillAtSec < items[smallest].fillAtSec) smallest = right; + if (smallest === i) break; + [items[smallest], items[i]] = [items[i], items[smallest]]; + i = smallest; + } + } + return top; + } +} + +/** + * Replays archived events against one strategy. + * + * @example + * ```ts + * const engine = new ReplayEngine({ strategy: new AlwaysFillStrategy(), params: { seed: 7 } }); + * const report = engine.run(parseIntentEvents(archiveText)); + * ``` + */ +export class ReplayEngine { + private readonly strategy: SimulatorStrategy; + private readonly params: SimParams; + private readonly prices: PriceBook; + private readonly feeRules: readonly FeeRule[]; + private readonly routing = new RoutingService(); + private readonly random: () => number; + + constructor(options: EngineOptions) { + this.strategy = options.strategy; + this.params = { ...DEFAULT_SIM_PARAMS, ...options.params }; + this.prices = options.prices ?? new PriceBook(); + this.feeRules = [{ ...DEFAULT_FEE_RULE, id: "sim-default", bps: this.params.feeBps }]; + this.random = createPrng(this.params.seed); + } + + /** + * Replay `events` in timestamp order and return the aggregated report. + * + * The input array is not mutated; events with equal timestamps keep + * their relative input order (stable sort), which keeps replays + * deterministic. + */ + run(events: readonly SimEvent[]): SimReport { + const sorted: SimEvent[] = [...events].sort((a, b) => a.ts - b.ts); + const heap = new FillHeap(); + const ctx: StrategyContext = { + nowSec: 0, + params: this.params, + prices: this.prices, + feeRules: this.feeRules, + random: this.random, + route: (intent) => this.routeFor(intent), + fee: (amount, intent) => + quoteFee(this.feeRules, [], { + amount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }), + valueUsd: (chain, symbol, amountBase, ts) => this.prices.usdValue(chain, symbol, amountBase, ts), + }; + + const totals = emptyTotals(); + let lastTs = Number.NEGATIVE_INFINITY; + + for (const event of sorted) { + // 1. Settle everything due at or before this moment. + while (heap.peek() && (heap.peek() as PendingFill).fillAtSec <= event.ts) { + this.settle(heap.pop() as PendingFill, totals); + } + // 2. The clock advanced → tick, then dispatch. + ctx.nowSec = event.ts; + if (event.ts > lastTs) this.strategy.onTick(ctx, event.ts); + lastTs = event.ts; + this.dispatch(event, ctx, heap, totals); + } + // 3. Stream over: settle the remainder (late vs on-time is decided per fill). + for (;;) { + const pending = heap.pop(); + if (!pending) break; + this.settle(pending, totals); + } + + return finalizeReport(this.strategy.name, this.params, totals); + } + + private dispatch( + event: SimEvent, + ctx: StrategyContext, + heap: FillHeap, + totals: SimulationTotals, + ): void { + const intent = event.intent; + totals.events += 1; + const kind = intent.event ?? "intent"; + let decision: QuoteDecision | null; + if (kind === "quote_request") { + totals.quoteRequests += 1; + decision = this.strategy.onQuoteRequest(ctx, intent); + } else { + totals.intentEvents += 1; + decision = this.strategy.onIntent(ctx, intent); + } + if (decision === null) { + totals.quotesDeclined += 1; + return; + } + totals.quotesSubmitted += 1; + const dstUnits = safeBigint(decision.dstAmount); + const minUnits = safeBigint(intent.minDstAmount); + if (dstUnits === null || minUnits === null || dstUnits < minUnits) { + // A quote below minDstAmount can never fill — failed + slash now. + totals.failedFills += 1; + totals.failedBelowMin += 1; + this.chargeSlash(totals); + return; + } + const delayMs = Math.max(0, decision.fillDelayMs ?? 0); + heap.push({ + fillAtSec: event.ts + delayMs / 1_000, + quotedAtSec: event.ts, + intent, + dstAmount: decision.dstAmount, + }); + } + + private settle(pending: PendingFill, totals: SimulationTotals): void { + const deadline = effectiveDeadline(pending.intent, this.params.fillWindowSec); + if (pending.fillAtSec > deadline) { + totals.failedFills += 1; + totals.failedLate += 1; + this.chargeSlash(totals); + return; + } + + totals.filled += 1; + totals.avgFillLatencyMs += (pending.fillAtSec - pending.quotedAtSec) * 1_000; + + const intent = pending.intent; + const fee = quoteFee(this.feeRules, [], { + amount: pending.dstAmount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }); + + // USD legs: captured-at-creation value first, archived price second. + const srcUsd = + intent.usdValueAtCreate !== undefined + ? intent.usdValueAtCreate + : this.prices.usdValue(intent.srcChain, intent.srcTokenSymbol ?? "", intent.srcAmount, intent.createdAt); + const dstUsd = this.prices.usdValue( + intent.dstChain ?? "stellar", + intent.dstTokenSymbol ?? "", + pending.dstAmount, + intent.createdAt, + ); + + if (srcUsd === null || dstUsd === null) { + // Unknown economics: count the fill, contribute no USD figures. + totals.unknownPriceFills += 1; + return; + } + + const dstUnits = Number(pending.dstAmount); + const feeUsd = Number.isFinite(dstUnits) && dstUnits > 0 ? dstUsd * (Number(fee.fee) / dstUnits) : 0; + totals.volumeUsd += srcUsd; + totals.feesPaidUsd += feeUsd; + totals.gasPaidUsd += this.params.gasUsdPerFill; + totals.pnlUsd += srcUsd - dstUsd - feeUsd - this.params.gasUsdPerFill; + } + + private chargeSlash(totals: SimulationTotals): void { + totals.slashEvents += 1; + totals.slashPenaltyUsd += this.params.slashPenaltyUsd; + } + + /** Direct two-hop-or-single route estimate from the pure routing module. */ + private routeFor(intent: ArchivedIntent): Route { + return this.routing.buildRoute( + { + address: intent.srcToken ?? "", + symbol: intent.srcTokenSymbol ?? "", + name: intent.srcTokenSymbol ?? "", + decimals: 18, + chain: intent.srcChain as SupportedChain, + }, + { + address: intent.dstToken ?? "", + symbol: intent.dstTokenSymbol ?? "", + name: intent.dstTokenSymbol ?? "", + decimals: 7, + chain: "stellar", + }, + "SIM_SOLVER", + { totalFeesUSD: 0, priceImpact: 0, estimatedFillTime: 60 }, + ); + } +} + +function emptyTotals(): SimulationTotals { + return { + events: 0, + intentEvents: 0, + quoteRequests: 0, + quotesDeclined: 0, + quotesSubmitted: 0, + filled: 0, + failedFills: 0, + failedLate: 0, + failedBelowMin: 0, + slashEvents: 0, + slashPenaltyUsd: 0, + fillRate: 0, + captureRate: 0, + volumeUsd: 0, + pnlUsd: 0, + feesPaidUsd: 0, + gasPaidUsd: 0, + unknownPriceFills: 0, + avgFillLatencyMs: 0, + }; +} + +function finalizeReport(strategy: string, params: SimParams, totals: SimulationTotals): SimReport { + totals.fillRate = totals.quotesSubmitted > 0 ? totals.filled / totals.quotesSubmitted : 0; + totals.captureRate = totals.intentEvents > 0 ? totals.filled / totals.intentEvents : 0; + totals.avgFillLatencyMs = totals.filled > 0 ? totals.avgFillLatencyMs / totals.filled : 0; + return { strategy, params, totals }; +} + +function safeBigint(value: string): bigint | null { + if (!/^\d+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} diff --git a/tools/simulator/fixtures/sample-intents.jsonl b/tools/simulator/fixtures/sample-intents.jsonl new file mode 100644 index 00000000..04a25a67 --- /dev/null +++ b/tools/simulator/fixtures/sample-intents.jsonl @@ -0,0 +1,11 @@ +# Sample archived intent stream for the simulation harness (issue #452). +# One intent per line, JSONL; field names mirror the public `intents` +# dataset schema. Unix timestamps are seconds. EVM USDC = 6 decimals, +# Stellar USDC = 7 decimals — dst amounts are ~0.4% below src so the +# default margin strategy quotes them. +{"intentId":"sample-1","createdAt":1759248000,"deadline":1759248120,"srcChain":"ethereum","srcToken":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","srcTokenSymbol":"USDC","srcAmount":"2500000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"24900000000","state":"open"} +{"intentId":"sample-2","createdAt":1759248030,"deadline":1759248330,"srcChain":"base","srcToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","srcTokenSymbol":"USDC","srcAmount":"1000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"9960000000","state":"open","usdValueAtCreate":1000} +{"intentId":"sample-3","createdAt":1759248060,"deadline":1759248660,"srcChain":"polygon","srcToken":"0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359","srcTokenSymbol":"USDC","srcAmount":"5000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"49800000000","state":"open","auction":{"startDstAmount":"50000000000","decayStart":1759248060,"decayEnd":1759248360}} +{"intentId":"sample-4","createdAt":1759248120,"deadline":1759248420,"srcChain":"arbitrum","srcToken":"0xaf88d065e77c8cC2239327C5EDb3A432268e5831","srcTokenSymbol":"USDC","srcAmount":"750000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"7470000000","state":"open"} +{"intentId":"sample-5","createdAt":1759248180,"deadline":1759248240,"srcChain":"optimism","srcToken":"0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85","srcTokenSymbol":"USDC","srcAmount":"400000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"3984000000","state":"open"} +{"intentId":"sample-6","createdAt":1759248240,"deadline":1759248840,"srcChain":"avalanche","srcToken":"0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E","srcTokenSymbol":"USDC","srcAmount":"3000000000","dstToken":"CBIGWZQX7VVI3XQEXJHGPFXCYYMFCC2KM6XK5SSXJHZ4HFQYM7AU2JAV","dstTokenSymbol":"USDC","minDstAmount":"29880000000","state":"open","event":"quote_request"} diff --git a/tools/simulator/fixtures/sample-prices.jsonl b/tools/simulator/fixtures/sample-prices.jsonl new file mode 100644 index 00000000..fa6ed726 --- /dev/null +++ b/tools/simulator/fixtures/sample-prices.jsonl @@ -0,0 +1,9 @@ +# Sample archived price snapshots (JSONL) for the simulation harness. +# `decimals` is optional (default: 7 on stellar, 18 elsewhere). +{"ts":1759247000,"chain":"ethereum","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"base","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"polygon","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"arbitrum","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"optimism","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"avalanche","symbol":"USDC","priceUsd":1,"decimals":6} +{"ts":1759247000,"chain":"stellar","symbol":"USDC","priceUsd":1,"decimals":7} diff --git a/tools/simulator/index.ts b/tools/simulator/index.ts new file mode 100644 index 00000000..818ada0f --- /dev/null +++ b/tools/simulator/index.ts @@ -0,0 +1,46 @@ +/** + * Solver simulation & backtesting harness (issue #452). + * + * Replays archived intent/price streams against pluggable solver + * strategies on a deterministic simulated clock, and reports PnL, + * fill-rate and slash-risk — optionally across a fill-window × fee-bps + * parameter sweep. Pure domain modules only (`src/auctions`, `src/fees`, + * `src/routing`): no Nest bootstrap, no network, no wall clock. + * + * @example + * ```ts + * import { parseIntentEvents, ReplayEngine, AlwaysFillStrategy } from "./tools/simulator"; + * + * const events = parseIntentEvents(archiveText); + * const report = new ReplayEngine({ strategy: new AlwaysFillStrategy(), params: { seed: 7 } }).run(events); + * ``` + */ +export type { + ArchivedIntent, + QuoteDecision, + SimEvent, + SimEventKind, + SimParams, + SimReport, + SimulatorStrategy, + SimulationTotals, + StrategyContext, +} from "./types"; +export { DEFAULT_SIM_PARAMS } from "./types"; +export { createPrng } from "./prng"; +export { PriceBook } from "./prices"; +export type { PricePoint, PriceRow } from "./prices"; +export { generateSyntheticArchive, parseIntentEvents, parsePriceBook } from "./archive"; +export { ReplayEngine } from "./engine"; +export type { EngineOptions } from "./engine"; +export { runSweep } from "./sweep"; +export type { SweepGrid, SweepOptions, SweepReport, SweepRow } from "./sweep"; +export { formatReport, formatSweep } from "./report"; +export { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +export type { AlwaysFillOptions } from "./strategies/always-fill.strategy"; +export { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +export type { MarginThresholdOptions } from "./strategies/margin-threshold.strategy"; +export { attemptGateReason, effectiveDeadline } from "./strategies/gate"; +export type { AttemptGate, GateReason } from "./strategies/gate"; +export { runCli, USAGE } from "./cli"; +export type { CliResult } from "./cli"; diff --git a/tools/simulator/perf.spec.ts b/tools/simulator/perf.spec.ts new file mode 100644 index 00000000..988729a8 --- /dev/null +++ b/tools/simulator/perf.spec.ts @@ -0,0 +1,36 @@ +/** + * Performance budget for the simulation harness (issue #452): + * replay 1M archived intents in under 5 minutes. + * + * The bound is asserted generously (300 s) because the issue's requirement + * is the contract; local/CI runs are expected to land well below it. The + * test measures `ReplayEngine.run` only — archive generation happens + * before the clock starts. + */ +import { generateSyntheticArchive } from "./archive"; +import { ReplayEngine } from "./engine"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; + +describe("replay performance (issue #452)", () => { + it( + "replays 1M intents in under 5 minutes", + () => { + const events = generateSyntheticArchive(1_000_000, 7); + const engine = new ReplayEngine({ + strategy: new AlwaysFillStrategy(), + params: { seed: 7 }, + }); + + const started = Date.now(); + const report = engine.run(events); + const elapsedMs = Date.now() - started; + + expect(report.totals.events).toBe(1_000_000); + expect(report.totals.quotesSubmitted).toBe(1_000_000); + expect(report.totals.filled).toBe(1_000_000); + expect(report.totals.slashEvents).toBe(0); + expect(elapsedMs).toBeLessThan(300_000); + }, + 300_000, + ); +}); diff --git a/tools/simulator/prices.ts b/tools/simulator/prices.ts new file mode 100644 index 00000000..dc828bac --- /dev/null +++ b/tools/simulator/prices.ts @@ -0,0 +1,88 @@ +/** + * Archived price snapshots for USD valuation during replay (issue #452). + * + * Prices arrive as JSONL rows (`{"ts","chain","symbol","priceUsd","decimals?"}`) + * and are looked up as-of a simulated timestamp: the latest snapshot at or + * before `ts`. Unknown pairs/timestamps return `null` — the harness never + * fabricates a price, mirroring `usdValueAtCreate` semantics in + * `src/intents/intents.types.ts`. + */ + +/** One price snapshot row from the archive. */ +export interface PriceRow { + /** Unix epoch seconds. */ + ts: number; + chain: string; + symbol: string; + priceUsd: number; + /** Token decimals for base-unit conversion; defaults per chain. */ + decimals?: number; +} + +/** A resolved price point. */ +export interface PricePoint { + priceUsd: number; + decimals: number; +} + +function defaultDecimals(chain: string): number { + return chain === "stellar" ? 7 : 18; +} + +/** + * Immutable, time-indexed price store. + * + * Rows are bucketed per `chain:symbol` and sorted once at construction; + * lookups binary-search the bucket, keeping replays O(log n) per valuation. + */ +export class PriceBook { + private readonly buckets = new Map(); + + constructor(rows: readonly PriceRow[] = []) { + for (const row of rows) { + const key = `${row.chain}:${row.symbol}`; + const bucket = this.buckets.get(key); + if (bucket) bucket.push(row); + else this.buckets.set(key, [row]); + } + for (const bucket of this.buckets.values()) { + bucket.sort((a, b) => a.ts - b.ts); + } + } + + /** Latest snapshot at or before `ts`, or null when the pair is unknown. */ + lookup(chain: string, symbol: string, ts: number): PricePoint | null { + const bucket = this.buckets.get(`${chain}:${symbol}`); + if (!bucket || bucket.length === 0) return null; + // Binary search for the last row with row.ts <= ts. + let lo = 0; + let hi = bucket.length - 1; + let best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (bucket[mid].ts <= ts) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + if (best < 0) return null; + const row = bucket[best]; + return { priceUsd: row.priceUsd, decimals: row.decimals ?? defaultDecimals(chain) }; + } + + /** + * USD value of `amountBase` base units at `ts`. + * + * Returns null when no snapshot exists — callers must treat unknown + * economics as "cannot evaluate", never as zero. + */ + usdValue(chain: string, symbol: string, amountBase: string, ts: number): number | null { + const point = this.lookup(chain, symbol, ts); + if (!point) return null; + const amount = Number(amountBase); + if (!Number.isFinite(amount)) return null; + return (amount / 10 ** point.decimals) * point.priceUsd; + } +} diff --git a/tools/simulator/prng.ts b/tools/simulator/prng.ts new file mode 100644 index 00000000..d69e958a --- /dev/null +++ b/tools/simulator/prng.ts @@ -0,0 +1,25 @@ +/** + * Deterministic, seedable PRNG for the simulation harness (issue #452). + * + * mulberry32: 32-bit state, uniform in `[0, 1)`. Chosen over + * `Math.random()` because replays must be bit-for-bit reproducible from a + * seed — including strategies that inject their own randomness through + * `StrategyContext.random`. + */ + +/** + * Create a deterministic generator from a 32-bit seed. + * + * @param seed Any number; coerced to uint32 so negative seeds are stable. + * @returns A function producing floats in `[0, 1)`. + */ +export function createPrng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/tools/simulator/report.ts b/tools/simulator/report.ts new file mode 100644 index 00000000..239b2cfe --- /dev/null +++ b/tools/simulator/report.ts @@ -0,0 +1,69 @@ +/** + * Human-readable report formatting for the simulation harness (issue #452). + * + * Pure string building over {@link SimReport} / {@link SweepReport} — no + * clock, no I/O — so formatted output is deterministic and diffable. + */ +import type { SimReport } from "./types"; +import type { SweepReport } from "./sweep"; + +function pct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function usd(value: number): string { + const sign = value < 0 ? "-" : ""; + return `${sign}$${Math.abs(value).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +function num(value: number): string { + return value.toLocaleString("en-US"); +} + +/** + * Format a single replay as an aligned plain-text report covering PnL, + * fill-rate and slash-risk (the three views the issue calls for). + */ +export function formatReport(report: SimReport): string { + const t = report.totals; + const p = report.params; + const lines = [ + `Strategy: ${report.strategy}`, + `Params: seed=${p.seed} feeBps=${p.feeBps} fillWindowSec=${p.fillWindowSec} minMarginBps=${p.minMarginBps} slashPenaltyUsd=${p.slashPenaltyUsd} gasUsdPerFill=${p.gasUsdPerFill}`, + `Events: ${num(t.events)} (${num(t.intentEvents)} intents, ${num(t.quoteRequests)} quote requests)`, + `Quotes: ${num(t.quotesSubmitted)} submitted, ${num(t.quotesDeclined)} declined`, + `Filled: ${num(t.filled)} fill rate ${pct(t.fillRate)} capture ${pct(t.captureRate)}`, + `Failed fills: ${num(t.failedFills)} (late ${num(t.failedLate)}, below min ${num(t.failedBelowMin)})`, + `Slash risk: ${num(t.slashEvents)} event(s), penalty ${usd(t.slashPenaltyUsd)}`, + `Volume: ${usd(t.volumeUsd)}`, + `Fees paid: ${usd(t.feesPaidUsd)} gas: ${usd(t.gasPaidUsd)}`, + `PnL: ${usd(t.pnlUsd)} (unpriced fills: ${num(t.unknownPriceFills)})`, + `Avg fill latency: ${num(Math.round(t.avgFillLatencyMs))} ms`, + ]; + return lines.join("\n"); +} + +/** + * Format a sweep as a GitHub-flavored Markdown table, one row per grid + * cell, ordered window-major then fee bps. + */ +export function formatSweep(sweep: SweepReport): string { + const lines = [ + `Strategy: ${sweep.strategy} — seed ${sweep.seed}`, + "", + "| fill window (s) | fee bps | fill rate | capture | filled | slashes | PnL (USD) | fees (USD) | volume (USD) |", + "|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ]; + for (const row of sweep.rows) { + const t = row.report.totals; + lines.push( + `| ${row.fillWindowSec} | ${row.feeBps} | ${pct(t.fillRate)} | ${pct(t.captureRate)} | ` + + `${num(t.filled)} | ${num(t.slashEvents)} | ${t.pnlUsd.toFixed(2)} | ` + + `${t.feesPaidUsd.toFixed(2)} | ${t.volumeUsd.toFixed(2)} |`, + ); + } + return lines.join("\n"); +} diff --git a/tools/simulator/strategies.spec.ts b/tools/simulator/strategies.spec.ts new file mode 100644 index 00000000..3828d064 --- /dev/null +++ b/tools/simulator/strategies.spec.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for the strategy hooks, the shared accept gate, and the + * fill-window deadline (issue #452). + */ +import { DEFAULT_FEE_RULE, quoteFee } from "../../src/fees/fee-engine"; +import { createPrng } from "./prng"; +import { PriceBook } from "./prices"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { attemptGateReason, effectiveDeadline } from "./strategies/gate"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; +import type { ArchivedIntent, StrategyContext } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Priced world: 1 USDC on both legs (EVM 6 decimals, Stellar 7). */ +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "u-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", // 1000 USDC + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", // 996 USDC + state: "open", + ...over, + }; +} + +function makeCtx(over: Partial = {}): StrategyContext { + const feeRules = [{ ...DEFAULT_FEE_RULE, id: "sim", bps: 5 }]; + const ctx: StrategyContext = { + nowSec: 1_000, + params: { ...DEFAULT_SIM_PARAMS }, + prices: makePrices(), + feeRules, + random: createPrng(1), + route: () => ({ steps: [], totalTime: 60, totalFeesUSD: 0, priceImpact: 0 }), + fee: (amount, intent) => + quoteFee(feeRules, [], { + amount, + srcChain: intent.srcChain, + dstChain: intent.dstChain ?? "stellar", + }), + // Reads through the final object so a `prices` override below applies here too. + valueUsd: (chain, symbol, amountBase, ts) => ctx.prices.usdValue(chain, symbol, amountBase, ts), + ...over, + }; + return ctx; +} + +describe("attemptGateReason (issue #452)", () => { + const base = { chains: ["ethereum"], minMarginBps: 0, nowSec: 1_000 }; + + it("accepts an open, on-chain, in-deadline intent", () => { + expect(attemptGateReason(makeIntent(), base)).toBeNull(); + }); + + it("applies precedence: state, deadline, chain, margin", () => { + expect(attemptGateReason(makeIntent({ state: "filled" }), base)).toBe("state"); + expect(attemptGateReason(makeIntent({ deadline: 1_000 }), base)).toBe("deadline"); + expect(attemptGateReason(makeIntent({ srcChain: "base" }), base)).toBe("chain"); + expect( + attemptGateReason(makeIntent({ minDstAmount: "1" }), { ...base, minMarginBps: 10 }), + ).toBe("margin"); + }); + + it("ignores state only when the caller opts in (replay rows)", () => { + const intent = makeIntent({ state: "filled" }); + expect(attemptGateReason(intent, { ...base, ignoreState: true })).toBeNull(); + expect(attemptGateReason(intent, base)).toBe("state"); + // A missing state is treated as open either way (creation-moment rows). + expect(attemptGateReason(makeIntent({ state: undefined }), base)).toBeNull(); + }); + + it("uses the bot's absolute margin heuristic", () => { + // 1_000_000 * (bps / 10_000) base units of minDstAmount. + expect(attemptGateReason(makeIntent({ minDstAmount: "999" }), { ...base, minMarginBps: 10 })).toBe("margin"); + expect(attemptGateReason(makeIntent({ minDstAmount: "1000" }), { ...base, minMarginBps: 10 })).toBeNull(); + }); +}); + +describe("effectiveDeadline (issue #452)", () => { + const intent = makeIntent({ createdAt: 1_000, deadline: 1_600 }); + + it("returns the intent deadline when no window is configured", () => { + expect(effectiveDeadline(intent, 0)).toBe(1_600); + }); + + it("tightens to createdAt + window when a window is configured", () => { + expect(effectiveDeadline(intent, 100)).toBe(1_100); + expect(effectiveDeadline(intent, 600)).toBe(1_600); + expect(effectiveDeadline(intent, 900)).toBe(1_600); + }); +}); + +describe("AlwaysFillStrategy (issue #452)", () => { + const strategy = new AlwaysFillStrategy(); + + it("quotes minDstAmount immediately for gate-passing intents", () => { + const decision = strategy.onIntent(makeCtx(), makeIntent()); + expect(decision).toEqual({ dstAmount: "9960000000", fillDelayMs: 0 }); + }); + + it("gives RFQ rounds the same answer as broadcast intents", () => { + expect(strategy.onQuoteRequest(makeCtx(), makeIntent())).toEqual( + strategy.onIntent(makeCtx(), makeIntent()), + ); + }); + + it("declines intents outside the chain allowlist", () => { + expect(strategy.onIntent(makeCtx(), makeIntent({ srcChain: "dogechain" }))).toBeNull(); + expect(new AlwaysFillStrategy({ chains: ["base"] }).onIntent(makeCtx(), makeIntent())).toBeNull(); + expect(new AlwaysFillStrategy({ chains: ["ethereum"] }).onIntent(makeCtx(), makeIntent())).not.toBeNull(); + }); + + it("has a no-op tick", () => { + expect(() => strategy.onTick(makeCtx(), 1_100)).not.toThrow(); + }); +}); + +describe("MarginThresholdStrategy (issue #452)", () => { + const strategy = new MarginThresholdStrategy(); + + it("quotes when the fee-adjusted margin clears the threshold", () => { + // margin = (1000 − 996 − 0.498) / 1000 → 35.02 bps. + expect(strategy.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent())).not.toBeNull(); + const higher = new MarginThresholdStrategy({ minMarginBps: 36 }); + expect(higher.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent())).toBeNull(); + }); + + it("declines when the destination leg cannot be priced", () => { + const noDstPrices = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }]), + }); + expect(strategy.onIntent(noDstPrices, makeIntent())).toBeNull(); + }); + + it("declines when the source leg cannot be valued (never guesses)", () => { + const noSrcPrices = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]), + }); + expect(strategy.onIntent(noSrcPrices, makeIntent())).toBeNull(); + }); + + it("prefers usdValueAtCreate over the price book for the source leg", () => { + // Source price missing from the book; captured value makes it workable. + const ctx = makeCtx({ + prices: new PriceBook([{ ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }]), + }); + expect(strategy.onIntent(ctx, makeIntent({ usdValueAtCreate: 1_000 }))).not.toBeNull(); + }); + + it("declines when routing says settlement cannot fit the window", () => { + const slowRoute = makeCtx({ route: () => ({ steps: [], totalTime: 10_000, totalFeesUSD: 0, priceImpact: 0 }) }); + expect(strategy.onIntent(slowRoute, makeIntent())).toBeNull(); + }); + + describe("Dutch-auction waiting", () => { + const sniper = new MarginThresholdStrategy({ minMarginBps: 50, useAuction: true }); + const auctionIntent = makeIntent({ + minDstAmount: "9900000000", // $990 floor + deadline: 1_600, + usdValueAtCreate: 1_000, // source leg: $1000 + auction: { + startDstAmount: "10100000000", // $1010 start → negative margin at t=1000 + decayStart: 1_000, + decayEnd: 1_100, + }, + }); + + it("waits for decay to clear the threshold, then fills at the decayed price", () => { + const decision = sniper.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent); + // price(t) = 1010 − 20·(t−1000)/100; margin crosses 50 bps at t=1078. + expect(decision).toEqual({ dstAmount: "9944000000", fillDelayMs: 78_000 }); + }); + + it("declines when even full decay misses the threshold", () => { + const greedy = new MarginThresholdStrategy({ minMarginBps: 5_000, useAuction: true }); + expect(greedy.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent)).toBeNull(); + }); + + it("fills immediately when the start price already clears the threshold", () => { + const early = new MarginThresholdStrategy({ minMarginBps: -10_000, useAuction: true }); + const decision = early.onIntent(makeCtx({ nowSec: 1_000 }), auctionIntent); + expect(decision?.fillDelayMs).toBe(0); + expect(decision?.dstAmount).toBe("10100000000"); + }); + + it("falls back to the plain quote when the intent has no auction", () => { + const plain = new MarginThresholdStrategy({ minMarginBps: 0, useAuction: true }); + const decision = plain.onIntent(makeCtx({ nowSec: 1_000 }), makeIntent({ auction: undefined })); + expect(decision?.dstAmount).toBe("9960000000"); + expect(decision?.fillDelayMs).toBe(0); + }); + }); +}); diff --git a/tools/simulator/strategies/always-fill.strategy.ts b/tools/simulator/strategies/always-fill.strategy.ts new file mode 100644 index 00000000..42b3c97d --- /dev/null +++ b/tools/simulator/strategies/always-fill.strategy.ts @@ -0,0 +1,61 @@ +/** + * Reference strategy #1 for the simulation harness (issue #452): + * accept-everything — the exact behaviour `scripts/solver-bot.ts` runs in + * live demos, extracted so it can be replayed offline. + * + * Quotes `minDstAmount` and fills immediately for every intent that clears + * the shared gate (chains + optional absolute margin + live deadline). + */ +import type { + ArchivedIntent, + QuoteDecision, + SimulatorStrategy, + StrategyContext, +} from "../types"; +import { attemptGateReason } from "./gate"; + +/** Tuning for {@link AlwaysFillStrategy}. */ +export interface AlwaysFillOptions { + /** Override the chain allowlist (defaults to `ctx.params.chains`). */ + chains?: readonly string[]; + /** Absolute-margin heuristic in bps; `0` disables it (the bot's default). */ + minMarginBps?: number; +} + +/** + * The naive "fill every open intent at `minDstAmount`" strategy. + * + * Deterministic: no prices, no fees, no randomness — the harness's + * baseline for comparing smarter strategies and for parameter sweeps. + */ +export class AlwaysFillStrategy implements SimulatorStrategy { + readonly name = "always-fill"; + + constructor(private readonly options: AlwaysFillOptions = {}) {} + + /** @inheritdoc */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — RFQ rounds get the same answer as broadcast intents. */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — this strategy observes no time-based signal. */ + onTick(_ctx: StrategyContext, _nowSec: number): void { + /* no time-dependent behaviour */ + } + + private decide(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + const reason = attemptGateReason(intent, { + chains: this.options.chains ?? ctx.params.chains, + minMarginBps: this.options.minMarginBps ?? 0, + nowSec: ctx.nowSec, + ignoreState: true, + }); + if (reason !== null) return null; + return { dstAmount: intent.minDstAmount, fillDelayMs: 0 }; + } +} diff --git a/tools/simulator/strategies/gate.ts b/tools/simulator/strategies/gate.ts new file mode 100644 index 00000000..e1734687 --- /dev/null +++ b/tools/simulator/strategies/gate.ts @@ -0,0 +1,64 @@ +/** + * Shared decision gate for strategies — and the exact accept gate the live + * reference bot runs (issue #452). + * + * `scripts/solver-bot.ts` imports {@link attemptGateReason} so the live + * bot and the `always-fill` reference strategy literally share one + * decision path: whatever passes here passes in a replay, and vice versa. + */ +import type { ArchivedIntent } from "../types"; + +/** Why a gate declined an intent; `null` means "attempt it". */ +export type GateReason = "state" | "deadline" | "chain" | "margin"; + +/** Inputs to {@link attemptGateReason}. */ +export interface AttemptGate { + /** Chains the solver trades. */ + chains: readonly string[]; + /** Minimum margin in bps using the bot's absolute-base-unit heuristic. */ + minMarginBps: number; + /** Current time (simulated clock in replay, wall clock in the bot). */ + nowSec: number; + /** + * Skip the `state === "open"` check. Replay rows describe the creation + * moment (or carry the intent's final state when exported from the + * public dataset), so strategies ignore state while the live bot — which + * sees real state transitions — does not. + */ + ignoreState?: boolean; +} + +/** + * Evaluate the shared accept gate. + * + * Precedence matches the reference bot: state, deadline, chain, margin. + * + * @param intent Intent (or bot-local view of one) to evaluate. + * @param gate Gate inputs. + * @returns The decline reason, or `null` when the intent should be attempted. + */ +export function attemptGateReason( + intent: Pick, + gate: AttemptGate, +): GateReason | null { + if (!gate.ignoreState && intent.state !== undefined && intent.state !== "open") return "state"; + if (intent.deadline <= gate.nowSec) return "deadline"; + if (!gate.chains.includes(intent.srcChain)) return "chain"; + if (gate.minMarginBps > 0 && Number(intent.minDstAmount) < 1_000_000 * (gate.minMarginBps / 10_000)) { + return "margin"; + } + return null; +} + +/** + * The moment after which a fill for `intent` is late under the current + * fill-window parameter: the intent deadline, tightened to + * `createdAt + fillWindowSec` when a window is configured. + * + * @param intent Intent being filled. + * @param fillWindowSec Protocol fill window in seconds; `0` disables it. + */ +export function effectiveDeadline(intent: ArchivedIntent, fillWindowSec: number): number { + if (fillWindowSec > 0) return Math.min(intent.deadline, intent.createdAt + fillWindowSec); + return intent.deadline; +} diff --git a/tools/simulator/strategies/margin-threshold.strategy.ts b/tools/simulator/strategies/margin-threshold.strategy.ts new file mode 100644 index 00000000..117e1c25 --- /dev/null +++ b/tools/simulator/strategies/margin-threshold.strategy.ts @@ -0,0 +1,170 @@ +/** + * Reference strategy #2 for the simulation harness (issue #452): + * price-aware margin thresholding with optional Dutch-auction sniping. + * + * Values both legs in USD (archived prices or `usdValueAtCreate`), quotes + * the protocol fee through `src/fees`, checks settlement feasibility + * through `src/routing`, and — when the intent carries an auction — waits + * for `src/auctions`' decay to push the margin over the threshold before + * filling. Declines whenever economics are unknown: never guess a price. + */ +import { dutchAuctionPrice } from "../../../src/auctions/dutch"; +import type { + ArchivedIntent, + QuoteDecision, + SimulatorStrategy, + StrategyContext, +} from "../types"; +import { attemptGateReason, effectiveDeadline } from "./gate"; + +/** Tuning for {@link MarginThresholdStrategy}. */ +export interface MarginThresholdOptions { + /** Threshold in bps of the source-leg value; defaults to `ctx.params.minMarginBps`. */ + minMarginBps?: number; + /** Override the chain allowlist (defaults to `ctx.params.chains`). */ + chains?: readonly string[]; + /** Wait for Dutch-auction decay to improve the margin before filling. */ + useAuction?: boolean; +} + +/** A chosen fill: when to fill and at what dst amount. */ +interface FillPlan { + delaySec: number; + dstAmount: string; +} + +/** + * Quote only when the trade clears a margin threshold after protocol fees. + * + * All arithmetic is deterministic (no randomness, integer-second auction + * search), so the strategy is directly comparable across sweep cells. + */ +export class MarginThresholdStrategy implements SimulatorStrategy { + readonly name = "margin-threshold"; + + constructor(private readonly options: MarginThresholdOptions = {}) {} + + /** @inheritdoc */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + return this.decide(ctx, intent); + } + + /** @inheritdoc — this strategy plans waits at decision time, not per tick. */ + onTick(_ctx: StrategyContext, _nowSec: number): void { + /* no time-dependent behaviour */ + } + + private decide(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null { + const threshold = this.options.minMarginBps ?? ctx.params.minMarginBps; + const reason = attemptGateReason(intent, { + chains: this.options.chains ?? ctx.params.chains, + minMarginBps: 0, + nowSec: ctx.nowSec, + ignoreState: true, + }); + if (reason !== null) return null; + + const srcUsd = this.srcValueUsd(ctx, intent); + if (srcUsd === null || srcUsd <= 0) return null; // unknown economics → decline + + const plan = + this.options.useAuction && intent.auction + ? this.planAuctionFill(ctx, intent, srcUsd, threshold) + : this.planImmediateFill(ctx, intent, srcUsd, threshold); + if (plan === null) return null; + + // Settlement must fit inside the fill window (routing module estimate). + const route = ctx.route(intent); + if (ctx.nowSec + plan.delaySec + route.totalTime > effectiveDeadline(intent, ctx.params.fillWindowSec)) { + return null; + } + return { dstAmount: plan.dstAmount, fillDelayMs: plan.delaySec * 1_000 }; + } + + /** Source-leg USD value: captured-at-creation first, archived prices second. */ + private srcValueUsd(ctx: StrategyContext, intent: ArchivedIntent): number | null { + if (intent.usdValueAtCreate !== undefined) return intent.usdValueAtCreate; + return ctx.valueUsd(intent.srcChain, intent.srcTokenSymbol ?? "", intent.srcAmount, intent.createdAt); + } + + /** + * Margin (bps of the source leg) of delivering `dstAmount`. + * + * `null` when the destination leg cannot be priced. + */ + private marginBps( + ctx: StrategyContext, + intent: ArchivedIntent, + dstAmount: string, + srcUsd: number, + ): number | null { + const dstUsd = ctx.valueUsd(intent.dstChain ?? "stellar", intent.dstTokenSymbol ?? "", dstAmount, intent.createdAt); + if (dstUsd === null) return null; + const dstUnits = Number(dstAmount); + if (!Number.isFinite(dstUnits) || dstUnits <= 0) return null; + const fee = ctx.fee(dstAmount, intent); + const feeUsd = dstUsd * (Number(fee.fee) / dstUnits); + return ((srcUsd - dstUsd - feeUsd) / srcUsd) * 10_000; + } + + private planImmediateFill( + ctx: StrategyContext, + intent: ArchivedIntent, + srcUsd: number, + threshold: number, + ): FillPlan | null { + const margin = this.marginBps(ctx, intent, intent.minDstAmount, srcUsd); + if (margin === null || margin < threshold) return null; + return { delaySec: 0, dstAmount: intent.minDstAmount }; + } + + /** + * Find the earliest integer-second moment (≥ now) where the decaying + * auction price clears the margin threshold. Margin is monotonic + * non-decreasing as the auction decays (the solver delivers less), so a + * binary search over the decay range is exact. + * + * @returns The fill plan, or `null` when even full decay misses the threshold. + */ + private planAuctionFill( + ctx: StrategyContext, + intent: ArchivedIntent, + srcUsd: number, + threshold: number, + ): FillPlan | null { + const auction = intent.auction; + if (!auction) return null; + const loSec = ctx.nowSec; + const hiSec = Math.min(auction.decayEnd, effectiveDeadline(intent, ctx.params.fillWindowSec)); + const priceAt = (ts: number): string => + dutchAuctionPrice(auction, ts, intent.minDstAmount); + const marginAt = (ts: number): number | null => + this.marginBps(ctx, intent, priceAt(ts), srcUsd); + + // Fully decayed must still clear the threshold, else decline. + const endMargin = marginAt(hiSec); + if (endMargin === null || endMargin < threshold) return null; + const startMargin = marginAt(loSec); + if (startMargin === null) return null; + if (startMargin >= threshold) return { delaySec: 0, dstAmount: priceAt(loSec) }; + + let low = loSec; + let high = hiSec; + while (low < high) { + const mid = Math.floor((low + high) / 2); + const margin = marginAt(mid); + if (margin !== null && margin >= threshold) high = mid; + else low = mid + 1; + } + const dstAmount = priceAt(low); + // Re-verify: quantised fees can leave the boundary short in theory. + const finalMargin = marginAt(low); + if (finalMargin === null || finalMargin < threshold) return null; + return { delaySec: low - loSec, dstAmount }; + } +} diff --git a/tools/simulator/sweep.spec.ts b/tools/simulator/sweep.spec.ts new file mode 100644 index 00000000..dbcae6bc --- /dev/null +++ b/tools/simulator/sweep.spec.ts @@ -0,0 +1,159 @@ +/** + * Sweep-mode tests (issue #452): grid construction, parameter plumbing, + * comparative determinism, and report formatting. + */ +import { PriceBook } from "./prices"; +import { formatReport, formatSweep } from "./report"; +import { runSweep } from "./sweep"; +import type { ArchivedIntent, SimEvent, SimulatorStrategy } from "./types"; +import { AlwaysFillStrategy } from "./strategies/always-fill.strategy"; +import { MarginThresholdStrategy } from "./strategies/margin-threshold.strategy"; + +function makeIntent(over: Partial = {}): ArchivedIntent { + return { + intentId: "s-1", + createdAt: 1_000, + deadline: 1_600, + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000000", + dstTokenSymbol: "USDC", + dstChain: "stellar", + minDstAmount: "9960000000", + state: "open", + ...over, + }; +} + +function makeEvents(): SimEvent[] { + return [ + { ts: 1_000, intent: makeIntent() }, + { ts: 1_010, intent: makeIntent({ intentId: "s-2", createdAt: 1_010 }) }, + { ts: 1_020, intent: makeIntent({ intentId: "s-3", createdAt: 1_020, deadline: 1_050 }) }, + ]; +} + +function makePrices(): PriceBook { + return new PriceBook([ + { ts: 0, chain: "ethereum", symbol: "USDC", priceUsd: 1, decimals: 6 }, + { ts: 0, chain: "stellar", symbol: "USDC", priceUsd: 1, decimals: 7 }, + ]); +} + +describe("runSweep (issue #452)", () => { + const grid = { fillWindowSecs: [0, 100], feeBps: [0, 5, 50] }; + + it("runs the Cartesian product, window-major then fee bps", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + params: { seed: 7 }, + prices: makePrices(), + grid, + }); + expect(sweep.strategy).toBe("always-fill"); + expect(sweep.seed).toBe(7); + expect(sweep.rows).toHaveLength(6); + expect(sweep.rows.map((r) => [r.fillWindowSec, r.feeBps])).toEqual([ + [0, 0], + [0, 5], + [0, 50], + [100, 0], + [100, 5], + [100, 50], + ]); + // Every cell replays the full stream with its own parameters. + for (const row of sweep.rows) { + expect(row.report.totals.events).toBe(3); + expect(row.report.params.fillWindowSec).toBe(row.fillWindowSec); + expect(row.report.params.feeBps).toBe(row.feeBps); + } + }); + + it("varies economics with fee bps: higher fees, lower PnL", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + prices: makePrices(), + grid: { fillWindowSecs: [0], feeBps: [0, 50] }, + }); + const [free, costly] = sweep.rows; + expect(free.report.totals.feesPaidUsd).toBe(0); + expect(costly.report.totals.feesPaidUsd).toBeGreaterThan(0); + expect(costly.report.totals.pnlUsd).toBeLessThan(free.report.totals.pnlUsd); + }); + + it("varies lateness with the fill window: tight windows slash more", () => { + const strategyFactory = () => + new (class implements SimulatorStrategy { + readonly name = "slow"; + onIntent(): { dstAmount: string; fillDelayMs: number } { + return { dstAmount: "9960000000", fillDelayMs: 60_000 }; + } + onQuoteRequest(): { dstAmount: string; fillDelayMs: number } { + return { dstAmount: "9960000000", fillDelayMs: 60_000 }; + } + onTick(): void { + /* no-op */ + } + })(); + const sweep = runSweep(makeEvents(), { + strategyFactory, + prices: makePrices(), + grid: { fillWindowSecs: [0, 60], feeBps: [5] }, + }); + // s-3 has deadline 1050 (createdAt 1020 + 30 s): a 60 s delay misses it + // regardless of window — the window itself adds no extra slashes here, + // but the plumbing must reach the engine either way. + for (const row of sweep.rows) { + expect(row.report.params.fillWindowSec).toBe(row.fillWindowSec); + } + expect(sweep.rows[0].report.totals.filled).toBe(2); + expect(sweep.rows[0].report.totals.failedLate).toBe(1); + expect(sweep.rows[1].report.totals.filled).toBe(2); + expect(sweep.rows[1].report.totals.failedLate).toBe(1); + }); + + it("is deterministic: two identical sweeps produce identical JSON", () => { + const options = { + strategyFactory: (params: { minMarginBps: number }) => + new MarginThresholdStrategy({ minMarginBps: params.minMarginBps }), + params: { seed: 11 }, + prices: makePrices(), + grid, + }; + const first = JSON.stringify(runSweep(makeEvents(), options)); + const second = JSON.stringify(runSweep(makeEvents(), options)); + expect(first).toBe(second); + }); +}); + +describe("report formatting (issue #452)", () => { + it("formatReport covers PnL, fill-rate and slash-risk", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + params: { seed: 3 }, + prices: makePrices(), + grid: { fillWindowSecs: [0], feeBps: [5] }, + }); + const text = formatReport(sweep.rows[0].report); + expect(text).toContain("Strategy: always-fill"); + expect(text).toContain("fill rate 100.0%"); + expect(text).toContain("Slash risk:"); + expect(text).toContain("PnL:"); + expect(text).toContain("seed=3 feeBps=5"); + }); + + it("formatSweep renders one markdown row per grid cell", () => { + const sweep = runSweep(makeEvents(), { + strategyFactory: () => new AlwaysFillStrategy(), + prices: makePrices(), + grid: { fillWindowSecs: [0, 100], feeBps: [5, 50] }, + }); + const table = formatSweep(sweep); + // Header + one data row per cell (the |---| separator never starts with "| "). + const rows = table.split("\n").filter((line) => line.startsWith("| ")); + expect(rows).toHaveLength(1 + sweep.rows.length); + expect(table).toContain("| fill window (s) | fee bps |"); + expect(table).toContain("| 0 | 50 |"); + expect(table).toContain("| 100 | 5 |"); + }); +}); diff --git a/tools/simulator/sweep.ts b/tools/simulator/sweep.ts new file mode 100644 index 00000000..916fb836 --- /dev/null +++ b/tools/simulator/sweep.ts @@ -0,0 +1,67 @@ +/** + * Parameter sweep mode for the simulation harness (issue #452): replay the + * same archive across a grid of protocol parameters — fill window × fee + * bps — and produce a comparative report. + */ +import { ReplayEngine } from "./engine"; +import type { EngineOptions } from "./engine"; +import type { PriceBook } from "./prices"; +import type { SimEvent, SimParams, SimReport, SimulatorStrategy } from "./types"; +import { DEFAULT_SIM_PARAMS } from "./types"; + +/** Sweep axes; the grid is their Cartesian product. */ +export interface SweepGrid { + /** Fill-window values in seconds (`0` = deadline only). */ + fillWindowSecs: readonly number[]; + /** Protocol fee values in basis points. */ + feeBps: readonly number[]; +} + +/** One grid cell's outcome. */ +export interface SweepRow { + fillWindowSec: number; + feeBps: number; + report: SimReport; +} + +/** Comparative result of a sweep, ordered window-major then fee bps. */ +export interface SweepReport { + strategy: string; + seed: number; + rows: SweepRow[]; +} + +/** Options for {@link runSweep}. */ +export interface SweepOptions { + /** Builds a fresh strategy per cell (strategies must not leak state between runs). */ + strategyFactory: (params: SimParams) => SimulatorStrategy; + /** Base parameters; each cell overrides `fillWindowSec` and `feeBps`. */ + params?: Partial; + prices?: PriceBook; + grid: SweepGrid; +} + +/** + * Replay `events` once per grid cell. + * + * Events are parsed/normalized once and reused; each cell gets a fresh + * engine (fresh counters, fresh seeded PRNG), so cells are independent and + * the whole sweep is deterministic for a given seed. + */ +export function runSweep(events: readonly SimEvent[], options: SweepOptions): SweepReport { + const base: SimParams = { ...DEFAULT_SIM_PARAMS, ...options.params }; + const rows: SweepRow[] = []; + for (const fillWindowSec of options.grid.fillWindowSecs) { + for (const feeBps of options.grid.feeBps) { + const params: SimParams = { ...base, fillWindowSec, feeBps }; + const engineOptions: EngineOptions = { + strategy: options.strategyFactory(params), + params, + prices: options.prices, + }; + const report = new ReplayEngine(engineOptions).run(events); + rows.push({ fillWindowSec, feeBps, report }); + } + } + return { strategy: options.strategyFactory(base).name, seed: base.seed, rows }; +} diff --git a/tools/simulator/types.ts b/tools/simulator/types.ts new file mode 100644 index 00000000..689bdd09 --- /dev/null +++ b/tools/simulator/types.ts @@ -0,0 +1,180 @@ +/** + * Core types for the solver simulation harness (issue #452). + * + * The harness replays an archived intent stream against a pluggable + * strategy on a deterministic, simulated clock — no network, no Nest + * bootstrap, no wall-clock reads. All randomness flows through the seeded + * PRNG exposed on {@link StrategyContext}, so identical inputs and seed + * always produce an identical {@link SimReport}. + */ +import type { DutchAuction } from "../../src/auctions/dutch"; +import type { FeeQuote, FeeRule } from "../../src/fees/fee-engine"; +import { SUPPORTED_CHAINS } from "../../src/intents/intents.types"; +import type { Route } from "../../src/intents/intents.types"; +import type { PriceBook } from "./prices"; + +/** Which strategy hook an archived event is dispatched to. */ +export type SimEventKind = "intent" | "quote_request"; + +/** + * One row of the archived intent stream. Field names intentionally mirror + * the public `intents` dataset schema (`src/datasets/schemas.ts`) so rows + * exported by `npm run export:datasets` can be replayed with minimal + * reshaping; timestamps are Unix epoch seconds. + */ +export interface ArchivedIntent { + intentId: string; + /** Creation time — the simulated clock position of this event. */ + createdAt: number; + /** Unix seconds after which the intent can no longer be filled. */ + deadline: number; + srcChain: string; + /** Source contract / mint address. */ + srcToken?: string; + srcTokenSymbol?: string; + /** Source amount in base units. */ + srcAmount: string; + /** Destination contract / mint address (Stellar). */ + dstToken?: string; + dstTokenSymbol?: string; + /** Minimum the user must receive, in dst-token base units. */ + minDstAmount: string; + /** Destination chain; defaults to `stellar`. */ + dstChain?: string; + /** + * Intent state as-of the event. Strategies may ignore it (see + * `AttemptGate.ignoreState`) — replay rows usually describe the creation + * moment, so the default is `open`. + */ + state?: string; + /** Source-leg USD value captured at creation, when the archive has it. */ + usdValueAtCreate?: number; + /** Dutch auction attached to the intent, if any. */ + auction?: DutchAuction; + /** Dispatch selector; defaults to `"intent"`. */ + event?: SimEventKind; +} + +/** A single archived event: intent delivery at simulated time `ts`. */ +export interface SimEvent { + ts: number; + intent: ArchivedIntent; +} + +/** + * A strategy's answer to a quote. `null` declines. A returned decision + * schedules a fill at `now + fillDelayMs`; the engine enforces the fill + * window and the intent deadline. + */ +export interface QuoteDecision { + /** Dst-token base units the solver commits to deliver. Must be ≥ `minDstAmount`. */ + dstAmount: string; + /** Simulated latency from decision to fill. Defaults to `0`. */ + fillDelayMs?: number; +} + +/** Protocol parameters the harness evaluates (sweepable — see `sweep.ts`). */ +export interface SimParams { + /** PRNG seed for {@link StrategyContext.random}. */ + seed: number; + /** Protocol fee in basis points applied to fills (via `src/fees`). */ + feeBps: number; + /** + * Fill window in seconds from intent creation. `0` disables the window + * and only the intent's own deadline constrains fills. + */ + fillWindowSec: number; + /** Chains the simulated solver trades. */ + chains: readonly string[]; + /** Default margin threshold (bps) handed to margin-style strategies. */ + minMarginBps: number; + /** USD penalty charged per accepted-but-unfilled intent (models a bond slash). */ + slashPenaltyUsd: number; + /** Flat USD settlement cost per fill. */ + gasUsdPerFill: number; +} + +/** Default protocol parameters used when none are supplied. */ +export const DEFAULT_SIM_PARAMS: SimParams = { + seed: 42, + feeBps: 5, + fillWindowSec: 0, + chains: [...SUPPORTED_CHAINS], + minMarginBps: 0, + slashPenaltyUsd: 100, + gasUsdPerFill: 0, +}; + +/** + * Everything a strategy may read while deciding. The engine reuses a + * single context object across the run and mutates `nowSec` — strategies + * must not retain the context after a hook returns. + */ +export interface StrategyContext { + /** Current simulated time (Unix seconds of the event being dispatched). */ + nowSec: number; + params: SimParams; + prices: PriceBook; + feeRules: readonly FeeRule[]; + /** Deterministic PRNG in `[0, 1)` seeded from `params.seed`. */ + random(): number; + /** Build the routing module's estimate for settling this intent. */ + route(intent: ArchivedIntent): Route; + /** Quote the protocol fee for delivering `amount` dst base units. */ + fee(amount: string, intent: ArchivedIntent): FeeQuote; + /** USD value of `amount` of `chain`/`symbol` at time `ts`, or null when unknown. */ + valueUsd(chain: string, symbol: string, amountBase: string, ts: number): number | null; +} + +/** + * A pluggable solver strategy under test (issue #452). The three hooks are + * the contract every strategy — the two reference implementations in + * `strategies/` included — must implement. + */ +export interface SimulatorStrategy { + readonly name: string; + /** A new intent arrived on the feed. Return a quote or `null` to decline. */ + onIntent(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null; + /** The protocol asked this solver for a quote (RFQ round). */ + onQuoteRequest(ctx: StrategyContext, intent: ArchivedIntent): QuoteDecision | null; + /** The simulated clock advanced to `nowSec`. */ + onTick(ctx: StrategyContext, nowSec: number): void; +} + +/** Aggregated outcome of one replay — the report the issue asks for. */ +export interface SimulationTotals { + /** Total events replayed. */ + events: number; + /** Events dispatched to `onIntent`. */ + intentEvents: number; + /** Events dispatched to `onQuoteRequest`. */ + quoteRequests: number; + quotesDeclined: number; + quotesSubmitted: number; + filled: number; + /** Accepted but never filled (counted regardless of reason). */ + failedFills: number; + failedLate: number; + failedBelowMin: number; + /** Unfilled acceptances — the slash-risk event count. */ + slashEvents: number; + slashPenaltyUsd: number; + /** `filled / quotesSubmitted`. */ + fillRate: number; + /** `filled / intentEvents`. */ + captureRate: number; + volumeUsd: number; + pnlUsd: number; + feesPaidUsd: number; + gasPaidUsd: number; + /** Fills whose USD legs could not be priced (excluded from USD totals). */ + unknownPriceFills: number; + avgFillLatencyMs: number; +} + +/** Full replay result: strategy identity + parameters + totals. */ +export interface SimReport { + strategy: string; + params: SimParams; + totals: SimulationTotals; +} diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 00000000..45557aea --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist-tools", + "declaration": false, + "declarationMap": false, + "lib": ["ES2022", "DOM"] + }, + "include": ["tools/**/*", "src/**/*"] +}