diff --git a/.env.example b/.env.example index a85a306..cc21732 100644 --- a/.env.example +++ b/.env.example @@ -452,6 +452,18 @@ HEALTH_READY_SUCCESS_THRESHOLD=2 HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 # Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). SOROBAN_RPC_HEALTH_URLS= +# JSON map of EVM chain name to HTTPS JSON-RPC URL for admin token verification. +# Example: {"ethereum":"https://ethereum.example/rpc","base":"https://base.example/rpc"} +EVM_RPC_URLS={} +# Public anonymised datasets (RFC 0001). Disabled until an operator opts in. +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR=./data/datasets # ─── Intents store (issue #404) ────────────────────────────────────────────── # memory | dual | postgres — supersedes INTENTS_PERSISTENCE above. diff --git a/.env.mainnet.example b/.env.mainnet.example index 7c47e0f..9b7d6f8 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -324,7 +324,6 @@ HEALTH_READY_SUCCESS_THRESHOLD=2 HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 # Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). SOROBAN_RPC_HEALTH_URLS= - # ─── Persistence ───────────────────────────────────────────────────────────── # REQUIRED: production must not lose intents on restart. Promote through # memory → dual → postgres per docs/runbooks/intents-store-migration.md. diff --git a/.env.staging.example b/.env.staging.example index 770671a..fa89b76 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -211,7 +211,6 @@ HEALTH_READY_SUCCESS_THRESHOLD=2 HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 # Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). SOROBAN_RPC_HEALTH_URLS= - # Staging runs the dual-write phase so the consistency verifier can soak # before production moves to postgres (docs/runbooks/intents-store-migration.md). INTENTS_STORE=dual diff --git a/.env.testnet.example b/.env.testnet.example index 304a7ce..e69de29 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -1,198 +0,0 @@ -# .env.testnet.example -# -# Environment template for LOCAL DEVELOPMENT against Stellar TESTNET. -# Copy to .env and fill in any values marked with . -# -# cp .env.testnet.example .env -# -# Testnet is safe to experiment with — tokens have no real value and contract -# deployments are free via Friendbot. Never reuse testnet keys on mainnet. -# -# Closes #136 - -# ─── Database ──────────────────────────────────────────────────────────────── -# Local Docker Compose default. Adjust if you use a remote or managed DB. -DATABASE_URL=postgresql://vortex:vortex@localhost:5432/vortex?schema=public - -# ─── Server ────────────────────────────────────────────────────────────────── -PORT=4000 -NODE_ENV=development - -# ─── Stellar / Soroban ─────────────────────────────────────────────────────── -STELLAR_NETWORK=testnet -SOROBAN_RPC_URL=https://soroban-testnet.stellar.org - -# Testnet contract IDs — leave blank until you have deployed contracts. -# The service boots without them; on-chain write paths are no-ops when empty. -SETTLEMENT_CONTRACT_ID= -SOLVER_REGISTRY_CONTRACT_ID= - -# Testnet signing key — generate a throwaway keypair, fund it with Friendbot, -# and paste the secret seed here. Never reuse this key on mainnet. -# -# # Generate a new key: -# npx @stellar/stellar-cli keys generate local-dev --network testnet -# npx @stellar/stellar-cli keys show local-dev -# -# # Or via the SDK: -# node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" -# -# # Fund it (testnet only): -# curl "https://friendbot.stellar.org/?addr=" -# -# Optional in development — leave blank to skip on-chain writes. -SOROBAN_SIGNING_KEY= - -# Fee percentile used when estimating Soroban inclusion fees. -# p50 is a safe default for testnet; raise to p90+ for time-sensitive mainnet txs. -SOROBAN_FEE_PERCENTILE=p50 - -# ─── CORS ──────────────────────────────────────────────────────────────────── -# Wildcard is fine for local development — tighten this in staging/production. -CORS_ORIGIN=* - -# ─── WebSocket ─────────────────────────────────────────────────────────────── -WS_MAX_CONNECTIONS=1000 - -# ─── Pluggable signer backend (issue #400) ─────────────────────────────────── -# SIGNER_BACKEND=local is the default for development. -# In production use SIGNER_BACKEND=vault and supply VAULT_ADDR + VAULT_TOKEN. -SIGNER_BACKEND=local -VAULT_ADDR= -VAULT_TOKEN= -VAULT_TRANSIT_KEY_NAME=vortex-signer -ALLOW_LOCAL_SIGNER_IN_PROD=false -# ─── Resource-exhaustion limits (issue #476) ───────────────────────────────── -# Maximum JSON nesting depth — rejects deeply-nested body attacks (default 10). -JSON_MAX_DEPTH=10 -# Maximum chain values in a single WS subscribe message (default 20). -WS_MAX_FILTER_CHAINS=20 -# Maximum active subscriptions per WS connection (default 10). -WS_MAX_SUBSCRIPTIONS=10 -# Postgres statement_timeout for standard queries in ms (default 5000). -DB_QUERY_TIMEOUT_MS=5000 -# Postgres statement_timeout for batch queries in ms (default 10000). -DB_BATCH_QUERY_TIMEOUT_MS=10000 -# Postgres statement_timeout for stats queries in ms (default 15000). -DB_STATS_QUERY_TIMEOUT_MS=15000 - -# Emergency kill-switch (issue #477) -# Postgres-backed so a pause survives a restart and reaches every replica. -KILLSWITCH_OPERATOR_TOKEN= -KILLSWITCH_REDIS_URL= -KILLSWITCH_POLL_MS=2000 -KILLSWITCH_PERSISTENCE=prisma - -# ─── Observability (optional) ──────────────────────────────────────────────── -# Leave blank to disable Sentry error reporting. -SENTRY_DSN= - -# debug | info | warn | error (defaults to "debug" in development) -LOG_LEVEL=debug - -# ── Shadow-mode divergence monitor (issue #401) ───────────────────────── -# Off by default in every environment. It runs read-only `simulateTransaction` -# calls against SETTLEMENT_CONTRACT_ID in parallel with the off-chain intent -# path and never signs or submits anything. -# -# SHADOW_SOURCE_ACCOUNT only has to be a valid Stellar public key: it is used to -# populate the source-account field of the simulated envelope and is never -# signed, never charged a fee and never broadcast. It must still be set, or -# every transition reports "contract_unconfigured". -SHADOW_MODE_ENABLED=false -SHADOW_SAMPLE_RATE=1 -SHADOW_QUEUE_MAX=256 -SHADOW_CONCURRENCY=4 -SHADOW_SOURCE_ACCOUNT= -# ─── Governance / Protocol Parameters ──────────────────────────────────────── -# On-chain governance parameters contract ID — leave blank to use code defaults. -PARAMS_CONTRACT_ID= - -# Poll interval in ms. 30 000 is fine for testnet. -PARAMS_POLL_INTERVAL_MS=30000 -# ─── Leader election ───────────────────────────────────────────────────────── -# Enable for multi-replica testnet deployments. -LEADER_ELECTION_ENABLED=false -LEADER_ELECTION_HEARTBEAT_MS=5000 - -# ─── Background jobs (issue #494) ──────────────────────────────────────────── -# api | worker | all — queue workers only run in "worker" or "all". -PROCESS_ROLE=all -# memory (single-process, dev/test) | bullmq (Redis-backed, uses REDIS_URL) -JOBS_DRIVER=memory -# Grace period for in-flight jobs on SIGTERM before they are returned to the queue. -JOBS_SHUTDOWN_TIMEOUT_MS=25000 - -# ─── Runtime feature flags (issue #495) ────────────────────────────────────── -# Change propagation across instances: memory (single instance) | redis -FLAGS_PUBSUB=memory -# Safety-net cache reload interval (ms) -FLAGS_REFRESH_MS=30000 -# Break-glass pins that win over DB state, e.g. onchain-dry-run=true -FLAG_OVERRIDES= - -# ─── Admin RBAC ────────────────────────────────────────────────────────────── -# Comma-separated id:role:secret (role = admin | superadmin, secret >= 16 chars). -# Sent as the x-admin-key header (the secret part). Empty disables admin APIs. -ADMIN_API_KEYS= - -# ─── Guardian emergency ingestion (issue #507) ─────────────────────────────── -# Guardian / security-council contract ID. Leave blank to disable ingestion. -GUARDIAN_CONTRACT_ID= - -# ─── Synthetic canary (issue #496) ─────────────────────────────────────────── -# Canary user + solver addresses; excluded from public stats and leaderboards. -CANARY_ADDRESSES= -# Egress/SSRF Protection -EGRESS_TIMEOUT_MS=10000 -EGRESS_MAX_REDIRECTS=3 -EGRESS_MAX_BODY_SIZE_BYTES=10485760 -SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org -WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com -ORACLE_ALLOWLIST=oracle.trusted.io -# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points. -MAX_USER_SLIPPAGE_BPS=100 -MAX_PREMIUM_BPS=50 -ORACLE_FAIL_OPEN_MAX_USD=100 -ORACLE_MAX_STALENESS_MS=60000 -# Public anonymised datasets (RFC 0001). Disabled until an operator opts in. -DATASETS_ENABLED=false -DATASETS_ANONYMIZE=true -DATASETS_SALT= -DATASETS_SALT_ROTATION_HOURS=24 -DATASETS_SALT_RETENTION_WINDOWS=2 -DATASETS_PUBLIC_BUCKET=vortex-public-datasets -DATASETS_STORAGE_KIND=memory -DATASETS_LOCAL_DIR=./data/datasets -# ─── WS gateway hardening (issue #455) ─────────────────────────────────────── -# Inbound frames larger than this close the socket (1009). -WS_MAX_PAYLOAD_BYTES=16384 -# Concurrent WS connections per client IP (0 = unlimited). -WS_MAX_CONNECTIONS_PER_IP=20 -# Trusted reverse-proxy hops for X-Forwarded-For (0 = socket address only). -WS_TRUST_PROXY_HOPS=0 -# Inbound token bucket per connection; repeat violators are disconnected. -WS_RATE_LIMIT_PER_SEC=10 -WS_RATE_LIMIT_BURST=20 -WS_RATE_LIMIT_MAX_VIOLATIONS=5 -# Outbound backpressure: messages held per slow consumer, socket buffer -# threshold (bytes), and what to do when the queue is full. -WS_OUTBOUND_QUEUE_MAX=1000 -WS_OUTBOUND_BUFFER_BYTES=1048576 -WS_SLOW_CONSUMER_POLICY=drop_oldest -# HS256 secret for solver JWTs from the SEP-10 auth flow (#442); >= 32 chars. -# Empty disables JWT auth on the WS gateway. -AUTH_JWT_SECRET= - -# ─── Health probes (issue #492) ────────────────────────────────────────────── -# Roles served by this process (api, ws, worker); readiness checks follow them. -SERVICE_ROLES=api,ws,worker -HEALTH_CHECK_INTERVAL_MS=5000 -# Readiness hysteresis: failures before not-ready, successes before ready again. -HEALTH_READY_FAILURE_THRESHOLD=3 -HEALTH_READY_SUCCESS_THRESHOLD=2 -# Liveness fails when event-loop delay exceeds this. -HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 -# Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). -SOROBAN_RPC_HEALTH_URLS= - diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a000c8..e69de29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,171 +0,0 @@ -# Changelog - -All notable changes to `vortex-backend` are documented here. - -This project follows [Conventional Commits](https://www.conventionalcommits.org/) -and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) conventions. -Commit message format is enforced via [commitlint](https://commitlint.js.org/) starting with v0.2.0. - -> **How to update this file** -> -> Add entries to the `[Unreleased]` section as you work on features and fixes. -> Organize by subsection (`Added`, `Fixed`, `Removed`, etc.) as defined below. -> -> **Mapping from Conventional Commits:** -> - `feat(scope): ...` → `[Added]` -> - `fix(scope): ...` → `[Fixed]` -> - `perf(scope): ...` → `[Changed]` (with note about performance improvement) -> - `refactor(scope): ...` → `[Changed]` (with scope of refactoring) -> - `docs(scope): ...` → `[Documentation]` (if user-facing; skip if internal only) -> - `chore(scope): ...` → Skip (internal tooling, no user-visible change) -> -> On version release, the `[Unreleased]` section is renamed to `[X.Y.Z]` with the -> release date, and a new `[Unreleased]` section is created. Version must be bumped -> in `package.json` to match. - ---- - -## [Unreleased] - -### Added -- Oracle-referenced `minDstAmount` validation on intent create: fair destination - value from the aggregator, rejection of slippage above `MAX_USER_SLIPPAGE_BPS` - unless the user signs `acknowledgeHighSlippage`, rejection of premium above - `MAX_PREMIUM_BPS`, and fail-open/fail-closed oracle policy - (Closes #434) -- Transactional outbox for on-chain writes: `onchain_outbox` table, intent change + outbox - row committed in one Prisma transaction, `OutboxRelayService` (SKIP LOCKED claims, per-intent - ordering, envelope hash persisted before submit, dead-lettering with alert), - `TxConfirmationService`, live `StellarTxService.invokeContract` submit path, and - `POST /api/v1/admin/outbox/:id/requeue` (Closes #396) -- Durable solver slashing saga: `pending_slashes` table (exactly-once per intent), - configurable challenge window, on-chain re-verification with clock-skew tolerance, - solver fill-proof and admin cancellation endpoints, compensation via `rollbackPenalty`, - metrics, alert rules and `docs/runbooks/slash-cancellation.md` (Closes #397) -- Admin slash cancellation and outbox requeue use the shared `AdminGuard` RBAC (`x-admin-key`) - and are recorded in `admin_audit_log` -- `scripts/generate-client.ts` — generates a typed TypeScript API client from the live - OpenAPI spec using `openapi-typescript` v7; output committed to `src/generated/` - (Closes #134) -- `CONTRIBUTING.md` — backend-specific onboarding guide covering prerequisites, - commands, DTO conventions, logger usage, module wiring, and PR checklist - (Closes #135) -- `.env.testnet.example` — environment template for local testnet development with - safe defaults and blank contract IDs (Closes #136) -- `.env.mainnet.example` — environment template for production mainnet with strict - CORS, required signing key, and all mandatory fields marked (Closes #136) -- `commitlint.config.js` — enforces Conventional Commits via `@commitlint/config-conventional` - (Closes #137) -- Husky `commit-msg` hook — runs commitlint on every local commit (Closes #137) -- CI job `commitlint` — validates commit messages on every push/PR in GitHub Actions - (Closes #137) -- `src/common/amount.ts` — shared, unit-tested base-units ↔ decimal conversion plus - the protocol fee (0.05 %) and quote-variance helpers; `IntentsController.quote()` - and `fill()` now use it instead of duplicated inline `BigInt`/decimal math - (Closes #272) -- `PATCH /api/v1/solvers/:address` (`UpdateSolverDto`, `buildUpdateSolverMessage`) — - signature-verified partial update of a solver's mutable profile fields - (`name`, `supportedChains`, `supportedTokens`, `avgFillTime`); immutable fields - are stripped by the DTO whitelist (Closes #273) -- Typed Swagger response documentation for every `SorobanController` and - `TokensController` route, including the account route's 400/429 responses - (Closes #271) -- `src/disputes/` — structured slash-dispute (appeal) workflow: authenticated - `POST /api/v1/solvers/disputes`, evidence auto-verification (fill-verifier), - reviewer lifecycle (`open → under_review → upheld | overturned`) with SLA - deadlines and RBAC (`ReviewerGuard`), treasury refund requests on overturn, - and public anonymised statistics (`docs/governance/dispute-reviewers.md`) -- `src/analytics/` — analytics layer over TimescaleDB continuous aggregates - (ADR-0001) with `GET /api/v1/analytics/{volume,fees,latency,solver-share}` - endpoints (`interval`, `from`, `to`, `chain`, `token` params), idempotent - event-driven ingestion, 1m/1h/1d rollups with retention, and historical backfill -- CI job `migration-lint` — lints the `prisma/migrations/**/migration.sql` a - change adds or modifies (via the `scripts/check-migrations.ts` squawk-equivalent - checker) for unsafe DDL: non-concurrent index builds/drops, column type - rewrites, `NOT NULL` without a default, and `LOCK TABLE`; also requires a - `down.sql` in every changed migration. Overrides use `-- squawk-ignore ` - with a mandatory `-- justification:`; fixture tests run via - `npm run test:scripts` (see `prisma/migrations/README.md`) - -### Fixed -- `SorobanModule` referenced `forwardRef`/`IntentsModule` without importing them; it now - imports `SolversModule` (what `EventIngestionService` actually needs) -- e2e `@stellar/stellar-sdk` mock now re-exports the real SDK and stubs only - `SorobanRpc.Server` (it previously lacked `Networks`, `Keypair`, … so no e2e suite could load) -- `IntentsService.create()` idempotency-key handling is now race-safe — concurrent - requests carrying the same key synchronously claim an in-flight slot before any - `await`, so exactly one intent is created and the losers replay its result - (Closes #274) -- `.github/workflows/ci.yml` failed to parse because the `backend` job's - `runs-on` was on the same line as its `name`, so no CI job could run -- `TokensModule` was missing `exports: [TokensService]` — `IntentsController` - could not inject `TokensService` outside the Jest test environment -- `IntentsModule` was missing `exports: [IntentsGateway]` — `StatsService` - could not inject `IntentsGateway` outside the Jest test environment - ---- - -## [0.1.0] — 2026-07-01 - -Initial versioned release of the NestJS rewrite. - -### Added (features) -- Full NestJS rebuild replacing the original Express server -- `ConfigModule` with Joi-based env validation and production signing-key enforcement -- `GET/POST /api/v1/intents` — intent listing, creation, accept, fill, cancel, quote -- `GET /api/v1/solvers` — solver leaderboard and stats -- `GET /api/v1/tokens` — supported token registry with chain filtering -- `GET /api/v1/stats` — protocol-level statistics -- `GET /health` — service health with database and Soroban RPC checks -- `WS /ws` — real-time intent feed with snapshot on connect and event replay buffer -- `GET /docs` — Swagger / OpenAPI UI (spec available at `/docs-json`) -- `GET /api/v1/chain/*` — Soroban RPC read endpoints (health, ledger, network, account) -- Soroban transaction signer service with fee estimation and retry/backoff -- Soroban contract event ingestion service -- Settlement contract integration for on-chain intent registration -- Solver registry contract integration (solver registry, deregistration, liveness) -- Routing service with address validation and price oracle integration -- Ed25519 signature verification on cancel/accept/fill/register endpoints -- Per-user intent rate limiting (10 creates / 60 s) on top of global IP throttle -- Idempotency key support on `POST /api/v1/intents` -- Intent expiry sweeper (30 s interval, configurable) -- WebSocket heartbeat and dead-client cleanup -- Topic-based filtering for WS subscriptions -- WS subscriber count cap (configurable via `WS_MAX_CONNECTIONS`) -- Request ID correlation across HTTP logs and error responses -- Structured Winston logging with configurable log level -- Prometheus metrics endpoint (`/metrics`) -- OpenTelemetry tracing instrumentation -- Sentry error alerting integration -- Helmet HTTP security headers with Swagger-compatible CSP -- Global CORS enforcement (wildcard rejected in `NODE_ENV=production`) -- 10 KB body size limit on all routes -- Append-only intent audit log -- Prisma ORM with PostgreSQL schema and migration tooling -- Reference solver bot (`npm run solver:demo`) with exponential reconnect backoff -- Seed script (`npm run seed`) -- Database backup/restore runbook (`RUNBOOK_BACKUP_RESTORE.md`) -- Load tests for concurrent intent accept race conditions and WS broadcast fanout -- Full unit and e2e test suite (coverage threshold: 70 % on all axes) - -### Fixed -- Precision loss in quote calculation for large bigint amounts -- `fillAmount` format validation before `BigInt` parsing -- `limit`/`offset` query param validation on intent listing -- `minDstAmount` `BigInt` parsing guard in `fill()` -- CORS wildcard default replaced with strict origin validation -- Log injection vectors sanitised - -### Security -- CORS wildcard (`*`) rejected at startup in `NODE_ENV=production` -- Ed25519 Stellar signature authentication on all state-mutating endpoints -- `SOROBAN_SIGNING_KEY` validated against Stellar secret seed format at startup - in production; process refuses to start without a well-formed key -- Secrets scanning via Gitleaks in CI -- `npm audit` at `--audit-level=high` in CI -- WS connection limit to prevent resource exhaustion - ---- - -[Unreleased]: https://github.com/vortex-protocol/vortex-backend/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/vortex-protocol/vortex-backend/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 65e0bd7..2583a36 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ POST /api/v1/intents/quote — get best quote from solvers GET /api/v1/solvers — solver leaderboard GET /api/v1/solvers/:addr/stats — solver performance stats GET /api/v1/tokens — supported tokens (filter by chain) +POST /api/v1/admin/tokens — register a token (admin key, on-chain metadata check) +PATCH /api/v1/admin/tokens — update status or re-verified metadata +DELETE /api/v1/admin/tokens — soft-delist a token (existing intents keep working) GET /api/v1/stats — protocol stats GET /health — service health WS /ws — real-time intent feed diff --git a/docs/adr/0004-token-registry-admin.md b/docs/adr/0004-token-registry-admin.md new file mode 100644 index 0000000..be53455 --- /dev/null +++ b/docs/adr/0004-token-registry-admin.md @@ -0,0 +1,53 @@ +# ADR 0004: Admin token registry with on-chain metadata checks + +- **Status**: Accepted +- **Date**: 2026-09-29 +- **Technical Story**: #435 — token registry admin API + +## Context + +Token decimals and symbols used to live in seed data. A wrong decimal silently +mis-prices every intent that uses that token. Adding a token required a deploy. + +## Decision + +`POST`, `PATCH` and `DELETE /api/v1/admin/tokens` are guarded by the existing +admin key (`x-admin-key`) and written to `admin_audit_log`. + +Before a create, or a patch that sends symbol, decimals or name, a chain-family +verifier reads the authoritative metadata: + +- EVM: `eth_getCode` plus `decimals()` and `symbol()`. `symbol()` accepts both + ABI `string` and non-standard `bytes32`. `name()` is best-effort. +- Stellar classic (`CODE:G...` or `native`): 7 decimals, symbol is the asset + code. These are not SACs. +- Stellar SAC (`C...`): read-only Soroban simulation of `symbol`, `decimals` + and `name`, using `SHADOW_SOURCE_ACCOUNT` as the unsigned envelope source. + +If the caller supplies a field that disagrees with the chain, the call returns +`METADATA_MISMATCH` and nothing is written. A missing contract returns +`TOKEN_NOT_FOUND` and nothing is written. RPC failures return 503 and nothing +is written. + +Status is `active`, `paused` or `delisted`. Delete sets `delisted` and keeps +the row. Discovery hides delisted tokens. `resolveSrcToken` / `resolveDstToken` +still return them, so an intent that already copied the token can be accepted +and filled. New creates go through `resolve*OrThrow`, which rejects paused and +delisted tokens. + +Successful writes replace the in-memory registry snapshot (the cache in front +of Postgres) and broadcast `token_list_updated` on the existing intent +WebSocket. There is no automated token-list ingestion. + +## Rollback + +Drop `tokens.status` and `tokens.asset_kind` and the `TokenStatus` enum. Intent +rows do not foreign-key tokens, so the drop does not cascade. Revert the admin +routes in the same release so clients stop calling them. + +## Consequences + +Operators need `EVM_RPC_URLS` for EVM verification and `SHADOW_SOURCE_ACCOUNT` +plus `SOROBAN_RPC_URL` for SAC verification. Classic assets do not need either. +A verification outage blocks new registrations; it does not block delist or +status-only patches, and it does not freeze intents that are already open. diff --git a/prisma/migrations/20260929000000_token_registry_status/migration.sql b/prisma/migrations/20260929000000_token_registry_status/migration.sql new file mode 100644 index 0000000..2442267 --- /dev/null +++ b/prisma/migrations/20260929000000_token_registry_status/migration.sql @@ -0,0 +1,16 @@ +-- Soft token lifecycle for the admin registry (issue #435). +-- Existing rows stay active. Stellar rows are marked SAC; EVM rows stay evm. +-- Rollback: drop the new columns and the TokenStatus enum. Intent rows do not +-- reference tokens by foreign key, so dropping these columns does not cascade. + +CREATE TYPE "TokenStatus" AS ENUM ('active', 'paused', 'delisted'); + +ALTER TABLE "tokens" ADD COLUMN "status" "TokenStatus" NOT NULL DEFAULT 'active'; +ALTER TABLE "tokens" ADD COLUMN "asset_kind" TEXT; + +UPDATE "tokens" +SET "asset_kind" = CASE WHEN "is_stellar" THEN 'stellar-sac' ELSE 'evm' END +WHERE "asset_kind" IS NULL; + +ALTER TABLE "tokens" ALTER COLUMN "asset_kind" SET NOT NULL; +ALTER TABLE "tokens" ALTER COLUMN "asset_kind" SET DEFAULT 'evm'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c1300fa..3679138 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,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 + + @@map("token_status") +} + // ─── 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 + @@ -220,6 +230,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]) diff --git a/src/tokens/admin-tokens.controller.spec.ts b/src/tokens/admin-tokens.controller.spec.ts new file mode 100644 index 0000000..d88f654 --- /dev/null +++ b/src/tokens/admin-tokens.controller.spec.ts @@ -0,0 +1,70 @@ +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { AdminGuard } from "../admin/admin.guard"; +import { AdminTokensController } from "./admin-tokens.controller"; +import { AdminTokensService } from "./admin-tokens.service"; + +const SECRET = "ops:admin:this-is-a-long-secret"; + +describe("AdminTokensController RBAC", () => { + let app: INestApplication; + const tokens = { + create: jest.fn().mockResolvedValue({ address: "0x1", status: "active" }), + update: jest.fn().mockResolvedValue({ address: "0x1", status: "paused" }), + delist: jest.fn().mockResolvedValue({ address: "0x1", status: "delisted" }), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [AdminTokensController], + providers: [ + AdminGuard, + Reflector, + { provide: AdminTokensService, useValue: tokens }, + { provide: ConfigService, useValue: { get: () => SECRET } }, + ], + }).compile(); + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + const body = { chain: "ethereum", address: "0x1111111111111111111111111111111111111111" }; + + it("rejects a missing admin key", async () => { + await request(app.getHttpServer()).post("/api/v1/admin/tokens").send(body).expect(401); + expect(tokens.create).not.toHaveBeenCalled(); + }); + + it("rejects an unknown admin key", async () => { + await request(app.getHttpServer()) + .post("/api/v1/admin/tokens") + .set("x-admin-key", "not-the-secret") + .send(body) + .expect(401); + }); + + it("allows an admin key to create, update and delist", async () => { + await request(app.getHttpServer()).post("/api/v1/admin/tokens").set("x-admin-key", "this-is-a-long-secret").send(body).expect(201); + await request(app.getHttpServer()) + .patch("/api/v1/admin/tokens") + .set("x-admin-key", "this-is-a-long-secret") + .send({ ...body, status: "paused" }) + .expect(200); + await request(app.getHttpServer()) + .delete("/api/v1/admin/tokens") + .set("x-admin-key", "this-is-a-long-secret") + .send(body) + .expect(200); + expect(tokens.create).toHaveBeenCalled(); + expect(tokens.update).toHaveBeenCalled(); + expect(tokens.delist).toHaveBeenCalled(); + }); +}); diff --git a/src/tokens/admin-tokens.controller.ts b/src/tokens/admin-tokens.controller.ts new file mode 100644 index 0000000..421c9b1 --- /dev/null +++ b/src/tokens/admin-tokens.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Delete, HttpCode, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AdminGuard, CurrentAdmin, RequireAdminRole } from "../admin/admin.guard"; +import { AdminPrincipal } from "../admin/admin-auth"; +import { AdminTokensService } from "./admin-tokens.service"; +import { CreateAdminTokenDto, DeleteAdminTokenDto, PatchAdminTokenDto } from "./dto/admin-token.dto"; + +/** + * Authenticated token registry (issue #435). + * + * Metadata is verified against the chain before it is stored. DELETE soft-delists + * the row; it does not remove it, so intents that already reference the token + * keep their copied metadata. + */ +@ApiTags("admin") +@ApiHeader({ name: "x-admin-key", required: true }) +@Controller("api/v1/admin/tokens") +@UseGuards(AdminGuard) +@RequireAdminRole("admin") +export class AdminTokensController { + constructor(private readonly tokens: AdminTokensService) {} + + @Post() + @ApiOperation({ summary: "Register a token after on-chain metadata verification" }) + create(@Body() dto: CreateAdminTokenDto, @CurrentAdmin() admin: AdminPrincipal) { + return this.tokens.create(dto, admin); + } + + @Patch() + @ApiOperation({ summary: "Update token status or re-verified metadata" }) + update(@Body() dto: PatchAdminTokenDto, @CurrentAdmin() admin: AdminPrincipal) { + return this.tokens.update(dto, admin); + } + + @Delete() + @HttpCode(200) + @ApiOperation({ summary: "Soft-delist a token. Existing intents are left intact." }) + remove(@Body() dto: DeleteAdminTokenDto, @CurrentAdmin() admin: AdminPrincipal) { + return this.tokens.delist(dto, admin); + } +} diff --git a/src/tokens/admin-tokens.service.spec.ts b/src/tokens/admin-tokens.service.spec.ts new file mode 100644 index 0000000..a4877fa --- /dev/null +++ b/src/tokens/admin-tokens.service.spec.ts @@ -0,0 +1,137 @@ +import { BadRequestException } from "@nestjs/common"; +import { AdminPrincipal } from "../admin/admin-auth"; +import { AdminAuditService } from "../admin/admin-audit.service"; +import { AdminTokensService, TokenListPublisher } from "./admin-tokens.service"; +import { InMemoryTokensRepository } from "./in-memory-tokens.repository"; +import { TokensService } from "./tokens.service"; +import { TokenVerifierService } from "./verification/token-verifier.service"; +import { EvmTokenVerifier } from "./verification/evm-token.verifier"; +import { StellarTokenVerifier } from "./verification/stellar-token.verifier"; +import { ERC20_DECIMALS_SELECTOR, ERC20_NAME_SELECTOR, ERC20_SYMBOL_SELECTOR } from "./verification/evm-symbol"; + +const ADMIN: AdminPrincipal = { id: "ops", role: "admin" }; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const FRESH = "0x1111111111111111111111111111111111111111"; + +function bytes32(text: string): string { + const word = Buffer.alloc(32); + Buffer.from(text).copy(word); + return `0x${word.toString("hex")}`; +} + +function uint(value: number): string { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function harness(meta: { symbol: string; decimals: number; name: string; code?: string }) { + const repo = new InMemoryTokensRepository(); + const publisher = new TokenListPublisher(); + const published: unknown[] = []; + publisher.publish = async (event) => { + published.push(event); + }; + const audit = { record: jest.fn().mockResolvedValue(undefined) }; + const evm = new EvmTokenVerifier({ + getCode: async () => meta.code ?? "0x6080", + call: async (_chain, _address, data) => { + if (data === ERC20_DECIMALS_SELECTOR) return uint(meta.decimals); + if (data === ERC20_SYMBOL_SELECTOR) return bytes32(meta.symbol); + if (data === ERC20_NAME_SELECTOR) return bytes32(meta.name); + throw new Error(data); + }, + }); + const stellar = new StellarTokenVerifier({ + read: async () => ({ symbol: "USDC", decimals: 7, name: "USD Coin" }), + }); + const service = new AdminTokensService( + repo, + new TokenVerifierService(evm, stellar), + audit as unknown as AdminAuditService, + publisher, + ); + return { repo, service, audit, published, tokens: new TokensService(repo) }; +} + +describe("AdminTokensService", () => { + it("persists on-chain metadata, audits the write, bumps the cache and emits token_list_updated", async () => { + const { service, audit, published, repo, tokens } = harness({ symbol: "DAI", decimals: 18, name: "Dai" }); + const before = repo.cacheGeneration(); + const saved = await service.create({ chain: "ethereum", address: FRESH }, ADMIN); + expect(saved).toMatchObject({ symbol: "DAI", decimals: 18, status: "active", assetKind: "evm" }); + expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ action: "token.create", actor: "ops" })); + expect(repo.cacheGeneration()).toBe(before + 1); + expect(published).toEqual([ + expect.objectContaining({ type: "token_list_updated", action: "created", address: FRESH, status: "active" }), + ]); + const listed = await tokens.getByChain("ethereum"); + expect(Array.isArray(listed.tokens) && listed.tokens.some((token) => token.address === FRESH)).toBe(true); + }); + + it("rejects a decimals mismatch and does not persist or emit", async () => { + const { service, repo, published, audit } = harness({ symbol: "DAI", decimals: 18, name: "Dai" }); + const before = repo.cacheGeneration(); + await expect( + service.create({ chain: "ethereum", address: FRESH, decimals: 6, symbol: "DAI" }, ADMIN), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repo.findByAddressAndChain(FRESH, "ethereum")).toBeUndefined(); + expect(repo.cacheGeneration()).toBe(before); + expect(published).toHaveLength(0); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it("rejects a symbol mismatch with both sides in the error body", async () => { + const { service } = harness({ symbol: "DAI", decimals: 18, name: "Dai" }); + try { + await service.create({ chain: "ethereum", address: FRESH, symbol: "USDC" }, ADMIN); + throw new Error("expected mismatch"); + } catch (err) { + const body = (err as BadRequestException).getResponse() as { code: string; mismatches: unknown[] }; + expect(body.code).toBe("METADATA_MISMATCH"); + expect(body.mismatches).toEqual([expect.objectContaining({ field: "symbol", supplied: "USDC", onChain: "DAI" })]); + } + }); + + it("does not persist when the contract is missing", async () => { + const { service, repo } = harness({ symbol: "DAI", decimals: 18, name: "Dai", code: "0x" }); + await expect(service.create({ chain: "ethereum", address: FRESH }, ADMIN)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.findByAddressAndChain(FRESH, "ethereum")).toBeUndefined(); + }); + + it("moves active → paused → delisted and hides only the delisted token", async () => { + const { service, tokens } = harness({ symbol: "USDC", decimals: 6, name: "USD Coin" }); + await service.update({ chain: "ethereum", address: USDC, status: "paused" }, ADMIN); + await expect(tokens.resolveSrcTokenOrThrow("ethereum", USDC)).rejects.toBeInstanceOf(BadRequestException); + const pausedList = await tokens.getByChain("ethereum"); + expect(Array.isArray(pausedList.tokens) && pausedList.tokens.some((token) => token.address === USDC)).toBe(true); + + await service.delist({ chain: "ethereum", address: USDC }, ADMIN); + const hidden = await tokens.getByChain("ethereum"); + expect(Array.isArray(hidden.tokens) && hidden.tokens.some((token) => token.address === USDC)).toBe(false); + await expect(tokens.resolveSrcToken("ethereum", USDC)).resolves.toMatchObject({ symbol: "USDC", decimals: 6 }); + }); + + it("leaves an already-created intent usable after the token is delisted", async () => { + const { service, tokens } = harness({ symbol: "USDC", decimals: 6, name: "USD Coin" }); + const resolved = await tokens.resolveSrcTokenOrThrow("ethereum", USDC); + const intent = { intentId: "intent-1", state: "open", srcToken: { ...resolved }, minDstAmount: "1" }; + await service.delist({ chain: "ethereum", address: USDC }, ADMIN); + expect(intent.state).toBe("open"); + expect(intent.srcToken.symbol).toBe("USDC"); + await expect(tokens.resolveSrcToken("ethereum", USDC)).resolves.toMatchObject({ address: USDC }); + await expect(tokens.resolveSrcTokenOrThrow("ethereum", USDC)).rejects.toBeInstanceOf(BadRequestException); + }); + + it("verifies a classic Stellar asset without a SAC reader hit", async () => { + const { service } = harness({ symbol: "DAI", decimals: 18, name: "Dai" }); + const saved = await service.create( + { + chain: "stellar", + address: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + symbol: "USDC", + decimals: 7, + }, + ADMIN, + ); + expect(saved.assetKind).toBe("stellar-classic"); + }); +}); diff --git a/src/tokens/admin-tokens.service.ts b/src/tokens/admin-tokens.service.ts new file mode 100644 index 0000000..81b4e87 --- /dev/null +++ b/src/tokens/admin-tokens.service.ts @@ -0,0 +1,210 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from "@nestjs/common"; +import { AdminPrincipal } from "../admin/admin-auth"; +import { AdminAuditService } from "../admin/admin-audit.service"; +import { SupportedChain } from "../intents/intents.types"; +import { CreateAdminTokenDto, DeleteAdminTokenDto, PatchAdminTokenDto } from "./dto/admin-token.dto"; +import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord, TokenStatus } from "./tokens.repository"; +import { TokenVerifierService } from "./verification/token-verifier.service"; +import { VerifiedTokenMetadata } from "./verification/evm-token.verifier"; + +/** WebSocket payload emitted after a successful registry mutation. */ +export interface TokenListUpdatedEvent { + type: "token_list_updated"; + action: "created" | "updated" | "delisted"; + chain: string; + address: string; + status: TokenStatus; +} + +/** + * Mutable publisher. IntentsModule points {@link publish} at the gateway + * after both modules exist, avoiding an import cycle. + */ +export class TokenListPublisher { + publish: (event: TokenListUpdatedEvent) => Promise = async () => undefined; +} + +/** + * Admin registry writes. On-chain metadata is verified before any insert or + * metadata update. A failed verification does not touch the repository. + */ +@Injectable() +export class AdminTokensService { + constructor( + @Inject(TOKENS_REPOSITORY) private readonly repo: ITokensRepository, + private readonly verifier: TokenVerifierService, + private readonly audit: AdminAuditService, + private readonly publisher: TokenListPublisher, + ) {} + + /** Register a token. Client decimals/symbol/name must match the chain when supplied. */ + async create(dto: CreateAdminTokenDto, admin: AdminPrincipal) { + const chain = dto.chain as SupportedChain; + const existing = this.repo.findByAddressAndChain(dto.address, chain); + if (existing && (existing.status ?? "active") !== "delisted") { + throw new ConflictException(`Token ${dto.address} on ${chain} is already registered`); + } + const verified = await this.verifyOrThrow(chain, dto.address); + this.assertNoMismatch(dto, verified); + const record = this.toRecord(chain, dto.address, verified, { + name: dto.name, + logoUri: dto.logoUri, + priceUSD: dto.priceUSD, + status: "active", + }); + const saved = await this.repo.save(record); + await this.audit.record({ + actor: admin.id, + action: "token.create", + target: this.target(chain, saved.address), + after: saved, + }); + await this.emit("created", saved); + return this.toResponse(saved); + } + + /** + * Update status or display fields. Symbol and decimals are re-verified when + * the caller sends them. Status-only changes (including delist) do not + * require the RPC, so an operator can pause a token during an outage. + */ + async update(dto: PatchAdminTokenDto, admin: AdminPrincipal) { + const chain = dto.chain as SupportedChain; + const existing = this.require(chain, dto.address); + const metadataChange = dto.symbol !== undefined || dto.decimals !== undefined || dto.name !== undefined; + let next: TokenRecord = { ...existing, status: dto.status ?? existing.status ?? "active" }; + if (metadataChange) { + const verified = await this.verifyOrThrow(chain, dto.address); + this.assertNoMismatch(dto, verified); + next = { + ...next, + symbol: verified.symbol, + decimals: verified.decimals, + name: dto.name ?? verified.name ?? existing.name, + assetKind: verified.assetKind, + }; + } + if (dto.logoUri !== undefined) next.logoUri = dto.logoUri; + if (dto.priceUSD !== undefined) next.priceUsd = dto.priceUSD; + const saved = await this.repo.save(next); + await this.audit.record({ + actor: admin.id, + action: "token.update", + target: this.target(chain, saved.address), + before: existing, + after: saved, + }); + await this.emit("updated", saved); + return this.toResponse(saved); + } + + /** Soft-delist. The row stays so intents that already reference the token keep working. */ + async delist(dto: DeleteAdminTokenDto, admin: AdminPrincipal) { + const chain = dto.chain as SupportedChain; + const existing = this.require(chain, dto.address); + const saved = await this.repo.setStatus(dto.address, chain, "delisted"); + if (!saved) throw new NotFoundException(`Token ${dto.address} on ${chain} was not found`); + await this.audit.record({ + actor: admin.id, + action: "token.delist", + target: this.target(chain, saved.address), + before: existing, + after: saved, + }); + await this.emit("delisted", saved); + return this.toResponse(saved); + } + + private async verifyOrThrow(chain: SupportedChain, address: string) { + try { + const verified = await this.verifier.verify(chain, address); + if (!verified.exists) { + throw new BadRequestException({ + code: "TOKEN_NOT_FOUND", + message: `No contract at ${address} on ${chain}`, + }); + } + return verified; + } catch (err) { + if (err instanceof BadRequestException) throw err; + throw new ServiceUnavailableException( + `Token verification failed and nothing was saved: ${(err as Error).message}`, + ); + } + } + + private assertNoMismatch( + supplied: { symbol?: string; decimals?: number; name?: string }, + onChain: VerifiedTokenMetadata, + ): void { + const mismatches = this.verifier.mismatches(supplied, onChain); + if (mismatches.length > 0) { + throw new BadRequestException({ + code: "METADATA_MISMATCH", + message: "Supplied token metadata does not match on-chain metadata", + mismatches, + }); + } + } + + private require(chain: SupportedChain, address: string): TokenRecord { + const existing = this.repo.findByAddressAndChain(address, chain); + if (!existing) throw new NotFoundException(`Token ${address} on ${chain} was not found`); + return existing; + } + + private toRecord( + chain: SupportedChain, + address: string, + verified: VerifiedTokenMetadata, + extra: { name?: string; logoUri?: string; priceUSD?: number; status: TokenStatus }, + ): TokenRecord { + return { + address, + chain, + symbol: verified.symbol, + name: extra.name ?? verified.name ?? verified.symbol, + decimals: verified.decimals, + logoUri: extra.logoUri ?? null, + priceUsd: extra.priceUSD ?? null, + isStellar: chain === "stellar", + status: extra.status, + assetKind: verified.assetKind, + }; + } + + private toResponse(record: TokenRecord) { + return { + address: record.address, + chain: record.chain, + symbol: record.symbol, + name: record.name, + decimals: record.decimals, + status: record.status ?? "active", + assetKind: record.assetKind ?? (record.isStellar ? "stellar-sac" : "evm"), + logoUri: record.logoUri ?? null, + priceUSD: record.priceUsd ?? null, + }; + } + + private target(chain: string, address: string): string { + return `token:${chain}:${address}`; + } + + private async emit(action: TokenListUpdatedEvent["action"], record: TokenRecord): Promise { + await this.publisher.publish({ + type: "token_list_updated", + action, + chain: record.chain, + address: record.address, + status: record.status ?? "active", + }); + } +} diff --git a/src/tokens/dto/admin-token.dto.ts b/src/tokens/dto/admin-token.dto.ts new file mode 100644 index 0000000..899deea --- /dev/null +++ b/src/tokens/dto/admin-token.dto.ts @@ -0,0 +1,78 @@ +import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator"; +import { SUPPORTED_CHAINS } from "../../intents/intents.types"; +import { TokenStatus } from "../tokens.repository"; + +export class CreateAdminTokenDto { + @IsIn(SUPPORTED_CHAINS) + chain!: string; + + @IsString() + @MinLength(1) + address!: string; + + @IsOptional() + @IsString() + symbol?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(255) + decimals?: number; + + @IsOptional() + @IsString() + logoUri?: string; + + @IsOptional() + @IsNumber() + priceUSD?: number; +} + +export class PatchAdminTokenDto { + @IsIn(SUPPORTED_CHAINS) + chain!: string; + + @IsString() + @MinLength(1) + address!: string; + + @IsOptional() + @IsIn(["active", "paused", "delisted"]) + status?: TokenStatus; + + @IsOptional() + @IsString() + symbol?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(255) + decimals?: number; + + @IsOptional() + @IsString() + logoUri?: string; + + @IsOptional() + @IsNumber() + priceUSD?: number; +} + +export class DeleteAdminTokenDto { + @IsIn(SUPPORTED_CHAINS) + chain!: string; + + @IsString() + @MinLength(1) + address!: string; +} diff --git a/src/tokens/prisma-tokens.repository.ts b/src/tokens/prisma-tokens.repository.ts index 2486013..ec178f4 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 908c14f..e3bbba1 100644 --- a/src/tokens/tokens.module.ts +++ b/src/tokens/tokens.module.ts @@ -1,13 +1,24 @@ import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpEgressService } from "../common/http-egress"; +import { AppConfig } from "../config/configuration"; import { PrismaService } from "../prisma/prisma.service"; +import { AdminTokensController } from "./admin-tokens.controller"; +import { AdminTokensService, TokenListPublisher } from "./admin-tokens.service"; import { TokensController } from "./tokens.controller"; 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 { EvmTokenVerifier } from "./verification/evm-token.verifier"; +import { HttpEvmChainReader } from "./verification/http-evm-chain.reader"; +import { SdkSacSimulator } from "./verification/sdk-sac.simulator"; +import { SimulatedSacReader } from "./verification/simulated-sac.reader"; +import { StellarTokenVerifier } from "./verification/stellar-token.verifier"; +import { TokenVerifierService } from "./verification/token-verifier.service"; @Module({ - controllers: [TokensController], + controllers: [TokensController, AdminTokensController], providers: [ { provide: TOKENS_REPOSITORY, @@ -22,8 +33,39 @@ import { PrismaTokensRepository } from "./prisma-tokens.repository"; return new InMemoryTokensRepository(); }, }, + TokenListPublisher, + { + provide: EvmTokenVerifier, + inject: [ConfigService], + useFactory: (config: ConfigService) => + new EvmTokenVerifier( + new HttpEvmChainReader( + config.get("evmRpcUrls", { infer: true }), + new HttpEgressService({ + timeoutMs: 10_000, + maxRedirects: 0, + maxBodySizeBytes: 1_000_000, + blockPrivateRanges: true, + }), + ), + ), + }, + { + provide: StellarTokenVerifier, + inject: [ConfigService], + useFactory: (config: ConfigService) => { + const simulator = new SdkSacSimulator( + config.get("stellar.sorobanRpcUrl", { infer: true }), + config.get("shadow.sourceAccount", { infer: true }), + config.get("stellar.network", { infer: true }), + ); + return new StellarTokenVerifier(new SimulatedSacReader(simulator)); + }, + }, + TokenVerifierService, + AdminTokensService, TokensService, ], - exports: [TokensService], + exports: [TokensService, TokenListPublisher], }) export class TokensModule {} diff --git a/src/tokens/tokens.repository.ts b/src/tokens/tokens.repository.ts index f0dcf02..2c091e2 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/verification/evm-symbol.spec.ts b/src/tokens/verification/evm-symbol.spec.ts new file mode 100644 index 0000000..320e98f --- /dev/null +++ b/src/tokens/verification/evm-symbol.spec.ts @@ -0,0 +1,30 @@ +import { decodeErc20String, decodeErc20Uint } from "./evm-symbol"; + +describe("ERC-20 metadata decoding", () => { + it("decodes a bytes32 symbol", () => { + const word = Buffer.alloc(32); + Buffer.from("USDC").copy(word); + expect(decodeErc20String(`0x${word.toString("hex")}`)).toBe("USDC"); + }); + + it("decodes an ABI dynamic string", () => { + const data = Buffer.from("USD Coin"); + const hex = [ + "0".repeat(62) + "20", + data.length.toString(16).padStart(64, "0"), + data.toString("hex").padEnd(64, "0"), + ].join(""); + expect(decodeErc20String(`0x${hex}`)).toBe("USD Coin"); + }); + + it("decodes decimals and rejects values above uint8", () => { + expect(decodeErc20Uint("0x" + "6".padStart(64, "0"))).toBe(6); + expect(decodeErc20Uint("0x" + "100".padStart(64, "0"))).toBeNull(); + }); + + it("rejects empty and non-hex payloads", () => { + expect(decodeErc20String("0x")).toBeNull(); + expect(decodeErc20String("0xzz")).toBeNull(); + expect(decodeErc20Uint("0x")).toBeNull(); + }); +}); diff --git a/src/tokens/verification/evm-symbol.ts b/src/tokens/verification/evm-symbol.ts new file mode 100644 index 0000000..0959374 --- /dev/null +++ b/src/tokens/verification/evm-symbol.ts @@ -0,0 +1,50 @@ +/** + * Decode an ERC-20 `symbol()` (or `name()`) eth_call result. + * + * Standard tokens return an ABI dynamic `string`. A common non-standard + * implementation (MKR and older DSToken forks) returns a raw `bytes32`. + * Both shapes are accepted. Empty or truncated payloads return null. + */ +export function decodeErc20String(hex: string): string | null { + const body = hex.trim().toLowerCase().replace(/^0x/, ""); + if (!body || /[^0-9a-f]/.test(body)) return null; + + if (body.length === 64) return decodeBytes32(body); + + if (body.length >= 192 && body.length % 64 === 0) { + const length = Number(BigInt(`0x${body.slice(64, 128)}`)); + if (!Number.isFinite(length) || length < 0 || length > 256) return null; + const data = body.slice(128, 128 + length * 2); + if (data.length !== length * 2) return null; + const text = Buffer.from(data, "hex").toString("utf8").replace(/\0+$/g, "").trim(); + return text.length > 0 ? text : null; + } + + return null; +} + +/** ABI-encoded uint256, the shape `decimals()` returns. */ +export function decodeErc20Uint(hex: string): number | null { + const body = hex.trim().toLowerCase().replace(/^0x/, ""); + if (!body || /[^0-9a-f]/.test(body) || body.length < 64) return null; + const value = BigInt(`0x${body.slice(0, 64)}`); + if (value > BigInt(255)) return null; + return Number(value); +} + +function hasControlChar(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) <= 0x1f) return true; + } + return false; +} + +function decodeBytes32(word: string): string | null { + const text = Buffer.from(word, "hex").toString("utf8").replace(/\0+$/g, "").trim(); + if (!text || hasControlChar(text)) return null; + return text; +} + +export const ERC20_DECIMALS_SELECTOR = "0x313ce567"; +export const ERC20_SYMBOL_SELECTOR = "0x95d89b41"; +export const ERC20_NAME_SELECTOR = "0x06fdde03"; diff --git a/src/tokens/verification/evm-token.verifier.spec.ts b/src/tokens/verification/evm-token.verifier.spec.ts new file mode 100644 index 0000000..3547b50 --- /dev/null +++ b/src/tokens/verification/evm-token.verifier.spec.ts @@ -0,0 +1,53 @@ +import { EvmChainReader, EvmTokenVerifier, EvmVerificationError } from "./evm-token.verifier"; +import { ERC20_DECIMALS_SELECTOR, ERC20_NAME_SELECTOR, ERC20_SYMBOL_SELECTOR } from "./evm-symbol"; + +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + +function bytes32(text: string): string { + const word = Buffer.alloc(32); + Buffer.from(text).copy(word); + return `0x${word.toString("hex")}`; +} + +function uint(value: number): string { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function reader(overrides: Partial> = {}): EvmChainReader { + const code = overrides.code ?? "0x60806040"; + const decimals = overrides.decimals ?? uint(6); + const symbol = overrides.symbol ?? bytes32("USDC"); + const name = overrides.name ?? bytes32("USD Coin"); + return { + getCode: jest.fn().mockResolvedValue(code), + call: jest.fn(async (_chain: string, _address: string, data: string) => { + if (data === ERC20_DECIMALS_SELECTOR) return decimals; + if (data === ERC20_SYMBOL_SELECTOR) return symbol; + if (data === ERC20_NAME_SELECTOR) return name; + throw new Error(`unexpected selector ${data}`); + }), + }; +} + +describe("EvmTokenVerifier", () => { + it("accepts a contract with matching decimals and a bytes32 symbol", async () => { + const verified = await new EvmTokenVerifier(reader()).verify("ethereum", USDC); + expect(verified).toMatchObject({ exists: true, assetKind: "evm", decimals: 6, symbol: "USDC", name: "USD Coin" }); + }); + + it("reports a missing contract without throwing", async () => { + const verified = await new EvmTokenVerifier(reader({ code: "0x" })).verify("ethereum", USDC); + expect(verified.exists).toBe(false); + }); + + it("rejects an undecodable symbol", async () => { + const verifier = new EvmTokenVerifier(reader({ symbol: "0x" + "00".repeat(32) })); + await expect(verifier.verify("ethereum", USDC)).rejects.toBeInstanceOf(EvmVerificationError); + }); + + it("rejects a non-address", async () => { + await expect(new EvmTokenVerifier(reader()).verify("ethereum", "not-an-address")).rejects.toBeInstanceOf( + EvmVerificationError, + ); + }); +}); diff --git a/src/tokens/verification/evm-token.verifier.ts b/src/tokens/verification/evm-token.verifier.ts new file mode 100644 index 0000000..6c1c7a9 --- /dev/null +++ b/src/tokens/verification/evm-token.verifier.ts @@ -0,0 +1,67 @@ +import { + decodeErc20String, + decodeErc20Uint, + ERC20_DECIMALS_SELECTOR, + ERC20_NAME_SELECTOR, + ERC20_SYMBOL_SELECTOR, +} from "./evm-symbol"; +import { TokenAssetKind } from "../tokens.repository"; + +/** Read-only EVM JSON-RPC surface used to verify ERC-20 metadata. */ +export interface EvmChainReader { + getCode(chain: string, address: string): Promise; + call(chain: string, address: string, data: string): Promise; +} + +export interface VerifiedTokenMetadata { + assetKind: TokenAssetKind; + decimals: number; + symbol: string; + name: string | null; + exists: boolean; +} + +export class EvmVerificationError extends Error { + constructor(message: string) { + super(message); + this.name = "EvmVerificationError"; + } +} + +/** + * Confirms an address is a contract and reads `decimals()` / `symbol()` / + * `name()` before any registry write. `symbol()` accepts ABI strings and + * bytes32. A failed `name()` is non-fatal; decimals and symbol are not. + */ +export class EvmTokenVerifier { + constructor(private readonly reader: EvmChainReader) {} + + async verify(chain: string, address: string): Promise { + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new EvmVerificationError(`'${address}' is not an EVM address`); + } + const code = await this.reader.getCode(chain, address); + if (isEmptyCode(code)) { + return { assetKind: "evm", decimals: 0, symbol: "", name: null, exists: false }; + } + const decimalsHex = await this.reader.call(chain, address, ERC20_DECIMALS_SELECTOR); + const symbolHex = await this.reader.call(chain, address, ERC20_SYMBOL_SELECTOR); + const decimals = decodeErc20Uint(decimalsHex); + const symbol = decodeErc20String(symbolHex); + if (decimals === null || symbol === null) { + throw new EvmVerificationError(`Could not decode ERC-20 metadata for ${address} on ${chain}`); + } + let name: string | null = null; + try { + name = decodeErc20String(await this.reader.call(chain, address, ERC20_NAME_SELECTOR)); + } catch { + name = null; + } + return { assetKind: "evm", decimals, symbol, name, exists: true }; + } +} + +function isEmptyCode(code: string): boolean { + const body = code.trim().toLowerCase().replace(/^0x/, ""); + return body.length === 0 || /^0+$/.test(body); +} diff --git a/src/tokens/verification/http-evm-chain.reader.spec.ts b/src/tokens/verification/http-evm-chain.reader.spec.ts new file mode 100644 index 0000000..24b8085 --- /dev/null +++ b/src/tokens/verification/http-evm-chain.reader.spec.ts @@ -0,0 +1,28 @@ +import { HttpEgressService } from "../../common/http-egress"; +import { HttpEvmChainReader } from "./http-evm-chain.reader"; + +describe("HttpEvmChainReader", () => { + it("posts eth_call and eth_getCode fixtures and surfaces RPC errors", async () => { + const fetch = jest + .fn() + .mockResolvedValueOnce({ body: JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x6080" }) }) + .mockResolvedValueOnce({ body: JSON.stringify({ jsonrpc: "2.0", id: 1, result: "0x" + "6".padStart(64, "0") }) }); + const egress = { fetch } as unknown as HttpEgressService; + const reader = new HttpEvmChainReader({ ethereum: "https://rpc.example" }, egress); + + await expect(reader.getCode("ethereum", "0xabc")).resolves.toBe("0x6080"); + await expect(reader.call("ethereum", "0xabc", "0x313ce567")).resolves.toMatch(/^0x0+6$/); + expect(fetch).toHaveBeenCalledWith( + "https://rpc.example", + expect.objectContaining({ method: "POST" }), + ); + + const failing = new HttpEvmChainReader({ ethereum: "https://rpc.example" }, { + fetch: jest.fn().mockResolvedValue({ body: JSON.stringify({ error: { message: "execution reverted" } }) }), + } as unknown as HttpEgressService); + await expect(failing.call("ethereum", "0xabc", "0x313ce567")).rejects.toThrow(/execution reverted/); + + const unconfigured = new HttpEvmChainReader({}, egress); + await expect(unconfigured.getCode("base", "0xabc")).rejects.toThrow(/No EVM RPC URL/); + }); +}); diff --git a/src/tokens/verification/http-evm-chain.reader.ts b/src/tokens/verification/http-evm-chain.reader.ts new file mode 100644 index 0000000..76421cd --- /dev/null +++ b/src/tokens/verification/http-evm-chain.reader.ts @@ -0,0 +1,42 @@ +import { EgressPurpose, HttpEgressService } from "../../common/http-egress"; +import { EvmChainReader } from "./evm-token.verifier"; + +/** + * JSON-RPC `eth_call` / `eth_getCode` reader. Tests inject a fake + * {@link EvmChainReader}; this class is the production adapter. + */ +export class HttpEvmChainReader implements EvmChainReader { + constructor( + private readonly urls: Record, + private readonly egress: HttpEgressService, + ) {} + + async getCode(chain: string, address: string): Promise { + return this.rpc(chain, "eth_getCode", [address, "latest"]); + } + + async call(chain: string, address: string, data: string): Promise { + return this.rpc(chain, "eth_call", [{ to: address, data }, "latest"]); + } + + private async rpc(chain: string, method: string, params: unknown[]): Promise { + const url = this.urls[chain.toLowerCase()]; + if (!url) { + throw new Error(`No EVM RPC URL configured for chain '${chain}'`); + } + const response = await this.egress.fetch(url, { + method: "POST", + purpose: EgressPurpose.RPC, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const parsed = JSON.parse(response.body) as { result?: unknown; error?: { message?: string } }; + if (parsed.error) { + throw new Error(parsed.error.message ?? `${method} failed`); + } + if (typeof parsed.result !== "string") { + throw new Error(`${method} returned no hex result`); + } + return parsed.result; + } +} diff --git a/src/tokens/verification/sdk-sac.simulator.ts b/src/tokens/verification/sdk-sac.simulator.ts new file mode 100644 index 0000000..d8af05c --- /dev/null +++ b/src/tokens/verification/sdk-sac.simulator.ts @@ -0,0 +1,45 @@ +import { Account, Contract, Networks, SorobanRpc, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { SacSimulator } from "./simulated-sac.reader"; + +/** + * Production SAC reader. Simulations are read-only: nothing is signed or + * submitted. `SHADOW_SOURCE_ACCOUNT` is only the envelope source. + */ +export class SdkSacSimulator implements SacSimulator { + private readonly server: SorobanRpc.Server; + private readonly passphrase: string; + + constructor( + rpcUrl: string, + private readonly sourceAccount: string, + network: string, + ) { + this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); + this.passphrase = + network === "mainnet" ? Networks.PUBLIC : network === "futurenet" ? Networks.FUTURENET : Networks.TESTNET; + } + + async simulate(contractId: string, method: "symbol" | "decimals" | "name"): Promise { + if (!this.sourceAccount) { + throw new Error("SHADOW_SOURCE_ACCOUNT is required to verify Stellar SAC metadata"); + } + const tx = new TransactionBuilder(new Account(this.sourceAccount, "0"), { + fee: "100", + networkPassphrase: this.passphrase, + }) + .addOperation(new Contract(contractId).call(method)) + .setTimeout(30) + .build(); + const response = await this.server.simulateTransaction(tx); + return retvalFromSimulation(response); + } +} + +/** Pull the first simulated return value out of a Soroban RPC response. */ +export function retvalFromSimulation(response: unknown): xdr.ScVal | null { + if (!response || typeof response !== "object") return null; + const record = response as { error?: unknown; results?: Array<{ retval?: xdr.ScVal }> }; + if (typeof record.error === "string" && record.error.length > 0) return null; + const retval = record.results?.[0]?.retval; + return retval ?? null; +} diff --git a/src/tokens/verification/simulated-sac.reader.ts b/src/tokens/verification/simulated-sac.reader.ts new file mode 100644 index 0000000..455573b --- /dev/null +++ b/src/tokens/verification/simulated-sac.reader.ts @@ -0,0 +1,54 @@ +import { xdr } from "@stellar/stellar-sdk"; +import { StellarSacReader } from "./stellar-token.verifier"; + +/** One simulated SEP-41 call. Production uses Soroban RPC; tests return fixtures. */ +export interface SacSimulator { + simulate(contractId: string, method: "symbol" | "decimals" | "name"): Promise; +} + +/** + * Pull symbol, decimals and name off a SAC by simulation. + * A null `symbol` or `decimals` simulation means the contract is absent. + */ +export class SimulatedSacReader implements StellarSacReader { + constructor(private readonly simulator: SacSimulator) {} + + async read(contractId: string): Promise<{ symbol: string; decimals: number; name: string | null } | null> { + const symbolVal = await this.simulator.simulate(contractId, "symbol"); + const decimalsVal = await this.simulator.simulate(contractId, "decimals"); + if (!symbolVal || !decimalsVal) return null; + const symbol = scValToString(symbolVal); + const decimals = scValToUint(decimalsVal); + if (!symbol || decimals === null) return null; + let name: string | null = null; + try { + const nameVal = await this.simulator.simulate(contractId, "name"); + name = nameVal ? scValToString(nameVal) : null; + } catch { + name = null; + } + return { symbol, decimals, name }; + } +} + +/** Decode a Soroban string, symbol, or bytes ScVal. Exported for fixture tests. */ +export function scValToString(val: xdr.ScVal): string | null { + const kind = val.switch().name; + let raw: string | Buffer | null = null; + if (kind === "scvString") raw = val.str(); + else if (kind === "scvSymbol") raw = val.sym(); + else if (kind === "scvBytes") raw = val.bytes(); + else return null; + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const trimmed = text.replace(/\0+$/g, "").trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** Decode a Soroban unsigned integer ScVal used by SAC `decimals()`. */ +export function scValToUint(val: xdr.ScVal): number | null { + const kind = val.switch().name; + if (kind === "scvU32") return val.u32(); + if (kind === "scvI32") return val.i32(); + if (kind === "scvU64") return Number(val.u64().toString()); + return null; +} diff --git a/src/tokens/verification/stellar-token.verifier.spec.ts b/src/tokens/verification/stellar-token.verifier.spec.ts new file mode 100644 index 0000000..078924e --- /dev/null +++ b/src/tokens/verification/stellar-token.verifier.spec.ts @@ -0,0 +1,63 @@ +import { xdr } from "@stellar/stellar-sdk"; +import { StellarSacReader, StellarTokenVerifier, StellarVerificationError } from "./stellar-token.verifier"; +import { retvalFromSimulation } from "./sdk-sac.simulator"; +import { SimulatedSacReader, scValToString, scValToUint } from "./simulated-sac.reader"; + +const SAC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +const CLASSIC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +function sac(meta: { symbol: string; decimals: number; name: string | null } | null): StellarSacReader { + return { read: jest.fn().mockResolvedValue(meta) }; +} + +describe("StellarTokenVerifier", () => { + it("treats CODE:ISSUER as a classic asset with 7 decimals", async () => { + const verified = await new StellarTokenVerifier(sac(null)).verify(CLASSIC); + expect(verified).toMatchObject({ exists: true, assetKind: "stellar-classic", symbol: "USDC", decimals: 7 }); + }); + + it("treats native XLM as a classic asset", async () => { + const verified = await new StellarTokenVerifier(sac(null)).verify("native"); + expect(verified).toMatchObject({ assetKind: "stellar-classic", symbol: "XLM", decimals: 7 }); + }); + + it("reads SAC metadata from the contract reader", async () => { + const verified = await new StellarTokenVerifier( + sac({ symbol: "USDC", decimals: 7, name: "USD Coin" }), + ).verify(SAC); + expect(verified).toMatchObject({ exists: true, assetKind: "stellar-sac", symbol: "USDC", decimals: 7 }); + }); + + it("reports a missing SAC contract", async () => { + const verified = await new StellarTokenVerifier(sac(null)).verify(SAC); + expect(verified.exists).toBe(false); + }); + + it("rejects an address that is neither classic nor a contract", async () => { + await expect(new StellarTokenVerifier(sac(null)).verify("nope")).rejects.toBeInstanceOf(StellarVerificationError); + }); +}); + +describe("SimulatedSacReader fixtures", () => { + it("decodes symbol and decimals ScVals and treats a null symbol as a missing contract", async () => { + const symbol = xdr.ScVal.scvSymbol(Buffer.from("USDC")); + const decimals = xdr.ScVal.scvU32(7); + const name = xdr.ScVal.scvString(Buffer.from("USD Coin")); + expect(scValToString(symbol)).toBe("USDC"); + expect(scValToUint(decimals)).toBe(7); + expect(retvalFromSimulation({ results: [{ retval: symbol }] })).toBe(symbol); + + const reader = new SimulatedSacReader({ + simulate: jest.fn(async (_id: string, method: string) => { + if (method === "symbol") return symbol; + if (method === "decimals") return decimals; + if (method === "name") return name; + return null; + }), + }); + await expect(reader.read(SAC)).resolves.toEqual({ symbol: "USDC", decimals: 7, name: "USD Coin" }); + + const missing = new SimulatedSacReader({ simulate: async () => null }); + await expect(missing.read(SAC)).resolves.toBeNull(); + }); +}); diff --git a/src/tokens/verification/stellar-token.verifier.ts b/src/tokens/verification/stellar-token.verifier.ts new file mode 100644 index 0000000..38f7f9c --- /dev/null +++ b/src/tokens/verification/stellar-token.verifier.ts @@ -0,0 +1,55 @@ +import { VerifiedTokenMetadata } from "./evm-token.verifier"; + +/** + * Reads SEP-41 `symbol` / `decimals` / `name` for a Stellar Asset Contract. + * Returning null means the contract is not on the ledger. + */ +export interface StellarSacReader { + read(contractId: string): Promise<{ symbol: string; decimals: number; name: string | null } | null>; +} + +export class StellarVerificationError extends Error { + constructor(message: string) { + super(message); + this.name = "StellarVerificationError"; + } +} + +const CLASSIC_ASSET = /^[A-Z0-9]{1,12}:G[A-Z2-7]{55}$/; +const SAC_CONTRACT = /^C[A-Z2-7]{55}$/; + +/** + * Stellar verification distinguishes classic assets (`CODE:ISSUER` or native + * XLM, always 7 decimals) from SACs, whose metadata comes from the contract. + */ +export class StellarTokenVerifier { + constructor(private readonly sac: StellarSacReader) {} + + async verify(address: string): Promise { + const trimmed = address.trim(); + if (trimmed === "native" || trimmed.toUpperCase() === "XLM") { + return { assetKind: "stellar-classic", decimals: 7, symbol: "XLM", name: "Stellar Lumens", exists: true }; + } + if (CLASSIC_ASSET.test(trimmed)) { + const symbol = trimmed.slice(0, trimmed.indexOf(":")); + return { assetKind: "stellar-classic", decimals: 7, symbol, name: symbol, exists: true }; + } + if (!SAC_CONTRACT.test(trimmed)) { + throw new StellarVerificationError(`'${address}' is not a Stellar classic asset or SAC contract`); + } + const meta = await this.sac.read(trimmed); + if (!meta) { + return { assetKind: "stellar-sac", decimals: 0, symbol: "", name: null, exists: false }; + } + if (!meta.symbol || !Number.isInteger(meta.decimals) || meta.decimals < 0 || meta.decimals > 255) { + throw new StellarVerificationError(`SAC ${trimmed} returned invalid metadata`); + } + return { + assetKind: "stellar-sac", + decimals: meta.decimals, + symbol: meta.symbol, + name: meta.name, + exists: true, + }; + } +} diff --git a/src/tokens/verification/token-verifier.service.ts b/src/tokens/verification/token-verifier.service.ts new file mode 100644 index 0000000..3ea7be5 --- /dev/null +++ b/src/tokens/verification/token-verifier.service.ts @@ -0,0 +1,55 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { SUPPORTED_CHAINS, SupportedChain } from "../../intents/intents.types"; +import { EvmTokenVerifier, VerifiedTokenMetadata } from "./evm-token.verifier"; +import { StellarTokenVerifier } from "./stellar-token.verifier"; + +export interface MetadataMismatch { + field: "symbol" | "decimals" | "name"; + supplied: string | number; + onChain: string | number | null; +} + +/** + * Dispatches token verification to the EVM or Stellar chain-family + * implementation and rejects client metadata that disagrees with the chain. + */ +@Injectable() +export class TokenVerifierService { + constructor( + private readonly evm: EvmTokenVerifier, + private readonly stellar: StellarTokenVerifier, + ) {} + + /** + * Read authoritative metadata. Throws when the chain family cannot verify + * the address. `exists: false` means the contract is not deployed. + */ + async verify(chain: SupportedChain, address: string): Promise { + if (!(SUPPORTED_CHAINS as readonly string[]).includes(chain)) { + throw new BadRequestException(`Unsupported chain '${chain}'`); + } + if (chain === "stellar") return this.stellar.verify(address); + return this.evm.verify(chain, address); + } + + /** + * Compare optional client fields with the chain. Empty client fields are + * not a mismatch — the on-chain value is used. Any supplied conflict is. + */ + mismatches( + supplied: { symbol?: string; decimals?: number; name?: string }, + onChain: VerifiedTokenMetadata, + ): MetadataMismatch[] { + const found: MetadataMismatch[] = []; + if (supplied.symbol !== undefined && supplied.symbol !== onChain.symbol) { + found.push({ field: "symbol", supplied: supplied.symbol, onChain: onChain.symbol }); + } + if (supplied.decimals !== undefined && supplied.decimals !== onChain.decimals) { + found.push({ field: "decimals", supplied: supplied.decimals, onChain: onChain.decimals }); + } + if (supplied.name !== undefined && onChain.name !== null && supplied.name !== onChain.name) { + found.push({ field: "name", supplied: supplied.name, onChain: onChain.name }); + } + return found; + } +}