From a1ba8e6b40ccd531be066a23e28bd53e12c06f81 Mon Sep 17 00:00:00 2001 From: Goodnessukaigwe Date: Tue, 29 Sep 2026 10:01:19 +0100 Subject: [PATCH] feat(tokens): verify registry metadata on chain before persistence Admin create and update check ERC-20 and Stellar metadata and soft-delist tokens so existing intents keep resolving. Co-authored-by: Cursor --- .env.example | 12 + .env.mainnet.example | 1 + .env.staging.example | 1 + .env.testnet.example | 1 + CHANGELOG.md | 1 + README.md | 3 + docs/adr/0004-token-registry-admin.md | 53 +++++ docs/runbooks/on-call.md | 2 + jest.config.js | 3 + package-lock.json | 160 ++++++++++++- package.json | 3 + .../migration.sql | 16 ++ prisma/schema.prisma | 16 ++ src/app.module.ts | 22 -- src/common/stellar-signature.ts | 3 + src/config/configuration.ts | 40 ++++ src/config/env.validation.ts | 14 +- src/governance/governance.module.ts | 4 +- src/health/health-indicator.registry.ts | 2 +- .../intents-sweeper.manual-trigger.spec.ts | 2 +- src/intents/intents.gateway.spec.ts | 23 +- src/intents/intents.gateway.ts | 8 +- src/intents/intents.module.ts | 19 +- src/intents/intents.service.shadow.spec.ts | 11 + src/intents/intents.service.spec.ts | 17 +- src/intents/intents.service.ts | 12 +- src/intents/solver-intent-matcher.ts | 19 +- src/intents/ws/connection-state.ts | 16 +- src/solvers/solvers.controller.ts | 99 +-------- src/soroban/event-ingestion.service.ts | 2 +- src/soroban/signer.service.spec.ts | 41 ++-- src/soroban/solver-registry.service.spec.ts | 20 ++ src/soroban/soroban.controller.spec.ts | 5 - src/soroban/soroban.module.ts | 20 +- src/soroban/soroban.service.ts | 8 +- src/soroban/stellar-tx.service.spec.ts | 50 +++-- src/soroban/stellar-tx.service.ts | 25 +-- src/soroban/tx-confirmation.service.ts | 2 +- src/tokens/admin-tokens.controller.spec.ts | 70 ++++++ src/tokens/admin-tokens.controller.ts | 41 ++++ src/tokens/admin-tokens.service.spec.ts | 137 ++++++++++++ src/tokens/admin-tokens.service.ts | 210 ++++++++++++++++++ src/tokens/dto/admin-token.dto.ts | 78 +++++++ src/tokens/in-memory-tokens.repository.ts | 42 +++- src/tokens/prisma-tokens.repository.ts | 50 ++++- src/tokens/tokens.module.ts | 46 +++- src/tokens/tokens.repository.ts | 18 ++ src/tokens/tokens.service.ts | 143 ++++-------- src/tokens/verification/evm-symbol.spec.ts | 30 +++ src/tokens/verification/evm-symbol.ts | 50 +++++ .../verification/evm-token.verifier.spec.ts | 53 +++++ src/tokens/verification/evm-token.verifier.ts | 67 ++++++ .../http-evm-chain.reader.spec.ts | 28 +++ .../verification/http-evm-chain.reader.ts | 42 ++++ src/tokens/verification/sdk-sac.simulator.ts | 45 ++++ .../verification/simulated-sac.reader.ts | 54 +++++ .../stellar-token.verifier.spec.ts | 63 ++++++ .../verification/stellar-token.verifier.ts | 55 +++++ .../verification/token-verifier.service.ts | 55 +++++ src/treasury/treasury.service.spec.ts | 4 +- src/treasury/treasury.service.ts | 9 +- test/__mocks__/nestjs-schedule.ts | 24 ++ test/jest-e2e.json | 3 +- 63 files changed, 1815 insertions(+), 358 deletions(-) create mode 100644 docs/adr/0004-token-registry-admin.md create mode 100644 prisma/migrations/20260929000000_token_registry_status/migration.sql create mode 100644 src/tokens/admin-tokens.controller.spec.ts create mode 100644 src/tokens/admin-tokens.controller.ts create mode 100644 src/tokens/admin-tokens.service.spec.ts create mode 100644 src/tokens/admin-tokens.service.ts create mode 100644 src/tokens/dto/admin-token.dto.ts create mode 100644 src/tokens/verification/evm-symbol.spec.ts create mode 100644 src/tokens/verification/evm-symbol.ts create mode 100644 src/tokens/verification/evm-token.verifier.spec.ts create mode 100644 src/tokens/verification/evm-token.verifier.ts create mode 100644 src/tokens/verification/http-evm-chain.reader.spec.ts create mode 100644 src/tokens/verification/http-evm-chain.reader.ts create mode 100644 src/tokens/verification/sdk-sac.simulator.ts create mode 100644 src/tokens/verification/simulated-sac.reader.ts create mode 100644 src/tokens/verification/stellar-token.verifier.spec.ts create mode 100644 src/tokens/verification/stellar-token.verifier.ts create mode 100644 src/tokens/verification/token-verifier.service.ts create mode 100644 test/__mocks__/nestjs-schedule.ts diff --git a/.env.example b/.env.example index 6ede42f..a48f7ea 100644 --- a/.env.example +++ b/.env.example @@ -311,3 +311,15 @@ 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 diff --git a/.env.mainnet.example b/.env.mainnet.example index 8b62508..5168e54 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -200,3 +200,4 @@ 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= +EVM_RPC_URLS={} diff --git a/.env.staging.example b/.env.staging.example index 91ba2b2..7acf144 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -120,3 +120,4 @@ 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= +EVM_RPC_URLS={} diff --git a/.env.testnet.example b/.env.testnet.example index e12d80f..18ca8a7 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -181,3 +181,4 @@ 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= +EVM_RPC_URLS={} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6a18d..7051821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s ## [Unreleased] ### Added +- Admin token registry (`POST`/`PATCH`/`DELETE /api/v1/admin/tokens`) verifies EVM and Stellar metadata on chain before persistence, soft-delists tokens, and emits `token_list_updated` (Closes #435) - `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) diff --git a/README.md b/README.md index bc6693e..cfec078 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,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/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index 276f33b..978bc78 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -520,6 +520,8 @@ handlers never wait for Redis. | Variable | Default | Effect | |---|---|---| | `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Upstream Soroban JSON-RPC endpoint | +| `EVM_RPC_URLS` | `{}` | JSON map of chain → JSON-RPC URL for admin ERC-20 verification | +| `SHADOW_SOURCE_ACCOUNT` | empty | Envelope source for read-only SAC metadata simulation | | `STELLAR_NETWORK` | `testnet` | Network passphrase selection | | `PORT` | `4000` | HTTP + WS listen port | | `NODE_ENV` | `development` | Log verbosity (set to `production` in prod) | diff --git a/jest.config.js b/jest.config.js index 9ebcfd1..5c7b8f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,9 @@ module.exports = { // Exclude the scripts sub-suite so tests aren't picked up twice. testPathIgnorePatterns: ["/scripts/"], collectCoverageFrom: ["**/*.(t|j)s"], + moduleNameMapper: { + "^@nestjs/schedule$": "/../test/__mocks__/nestjs-schedule.ts", + }, }, // ── Scripts suite (ledger-utils, etc.) ───────────────────────────────── diff --git a/package-lock.json b/package-lock.json index 3aa78ac..0291b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.28", @@ -39,10 +40,12 @@ "helmet": "^7.1.0", "ioredis": "^6.0.0", "joi": "^18.2.3", + "parquetjs": "^0.11.2", "pg": "8.13.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "undici": "^7.30.0", "uuid": "^10.0.0", "winston": "^3.13.0", "ws": "^8.18.0", @@ -2818,6 +2821,22 @@ } } }, + "node_modules/@nestjs/schedule": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz", + "integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==", + "license": "MIT", + "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": "11.1.0", "dev": true, @@ -5400,6 +5419,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz", + "integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==", + "license": "MIT" + }, "node_modules/@types/memcached": { "version": "2.2.10", "license": "MIT", @@ -6372,6 +6397,13 @@ "node": "*" } }, + "node_modules/bindings": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", + "integrity": "sha512-u4cBQNepWxYA55FunZSM7wMi55yQaN0otnhhilNoWHq0MfOfJeQx0v0mRRpolGOExPjZcl6FtB0BB8Xkb88F0g==", + "license": "MIT", + "optional": true + }, "node_modules/bintrees": { "version": "1.0.2", "license": "MIT" @@ -6460,6 +6492,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.7", "dev": true, @@ -6511,6 +6552,15 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/buffer": { "version": "6.0.3", "funding": [ @@ -7121,6 +7171,23 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cron-parser": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.1.tgz", @@ -8911,6 +8978,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "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", @@ -10123,6 +10196,17 @@ "node": ">=12" } }, + "node_modules/lzo": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/lzo/-/lzo-0.4.11.tgz", + "integrity": "sha512-apQHNoW2Alg72FMqaC/7pn03I7umdgSVFt2KRkCXXils4Z9u3QBh1uOtl2O5WmZIDLd9g6Lu4lIdOLmiSTFVCQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "~1.2.1" + } + }, "node_modules/magic-string": { "version": "0.30.17", "dev": true, @@ -10542,7 +10626,6 @@ }, "node_modules/node-int64": { "version": "0.4.0", - "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -10596,6 +10679,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-stream": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/object-stream/-/object-stream-0.0.1.tgz", + "integrity": "sha512-+NPJnRvX9RDMRY9mOWOo/NDppBjbZhXirNNSu2IBnuNboClC9h1ZGHXgHBLDbJMHsxeJDq922aVmG5xs24a/cA==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -10788,6 +10879,27 @@ "node": ">=6" } }, + "node_modules/parquetjs": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/parquetjs/-/parquetjs-0.11.2.tgz", + "integrity": "sha512-Y6FOc3Oi2AxY4TzJPz7fhICCR8tQNL3p+2xGQoUAMbmlJBR7+JJmMrwuyMjIpDiM7G8Wj/8oqOH4UDUmu4I5ZA==", + "license": "MIT", + "dependencies": { + "brotli": "^1.3.0", + "bson": "^1.0.4", + "int53": "^0.2.4", + "object-stream": "0.0.1", + "snappyjs": "^0.6.0", + "thrift": "^0.11.0", + "varint": "^5.0.0" + }, + "engines": { + "node": ">=7.6" + }, + "optionalDependencies": { + "lzo": "^0.4.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "dev": true, @@ -11230,6 +11342,17 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.15.3", "license": "BSD-3-Clause", @@ -11803,6 +11926,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sodium-native": { "version": "4.3.3", "license": "MIT", @@ -12344,6 +12473,20 @@ "dev": true, "license": "MIT" }, + "node_modules/thrift": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/thrift/-/thrift-0.11.0.tgz", + "integrity": "sha512-UpsBhOC45a45TpeHOXE4wwYwL8uD2apbHTbtBvkwtUU4dNwCjC7DpQTjw2Q6eIdfNtw+dKthdwq94uLXTJPfFw==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0", + "q": "^1.5.0", + "ws": ">= 2.2.3" + }, + "engines": { + "node": ">= 4.1.0" + } + }, "node_modules/through": { "version": "2.3.8", "dev": true, @@ -12724,6 +12867,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "license": "MIT" @@ -12885,6 +13037,12 @@ "node": ">= 0.10" } }, + "node_modules/varint": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", diff --git a/package.json b/package.json index 2e44fb7..a4a8255 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.28", @@ -60,10 +61,12 @@ "helmet": "^7.1.0", "ioredis": "^6.0.0", "joi": "^18.2.3", + "parquetjs": "^0.11.2", "pg": "8.13.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "undici": "^7.30.0", "uuid": "^10.0.0", "winston": "^3.13.0", "ws": "^8.18.0", 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 7a048e5..5657a0b 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 + @@ -183,6 +193,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/app.module.ts b/src/app.module.ts index b93ab1d..4579b71 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,19 +11,15 @@ import { SolversModule } from "./solvers/solvers.module"; import { StatsModule } from "./stats/stats.module"; import { SorobanModule } from "./soroban/soroban.module"; import { RoutingModule } from "./routing/routing.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { KillSwitchModule } from "./killswitch/killswitch.module"; import { PrismaModule } from "./prisma/prisma.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { TreasuryModule } from "./treasury/treasury.module"; import { GovernanceModule } from "./governance/governance.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { LeaderElectionModule } from "./common/leader-election"; import { AdminModule } from "./admin/admin.module"; import { JobsModule } from "./jobs/jobs.module"; import { FlagsModule } from "./flags/flags.module"; import { GuardianStateModule } from "./governance/guardian-state.service"; -import { DatasetsModule } from "./datasets/datasets.module"; @Module({ imports: [ @@ -35,7 +31,6 @@ import { DatasetsModule } from "./datasets/datasets.module"; limit: 100, }, ]), - // Enable scheduled tasks (cron jobs) ScheduleModule.forRoot(), ConfigModule, PrismaModule, @@ -43,23 +38,8 @@ import { DatasetsModule } from "./datasets/datasets.module"; // for the whole app. Must be imported here or the global providers never // become visible to other modules (e.g. IntentsSweeperService). MetricsModule, - // Emergency pause control plane (issue #477). @Global() so KillSwitchGuard - // can gate write handlers in any module. KillSwitchModule, - // MetricsModule registers GET /metrics and the HTTP metrics interceptor. - // It is @Global(), so registering it here makes MetricsService injectable - // everywhere — which IntentsSweeperService, ShadowService and the SLO - // emitters all rely on. It must be listed exactly once, in the root - // module: dropping it from here leaves Nest unable to resolve - // MetricsService and the application fails to boot. - MetricsModule, - MetricsModule, - // Leader election must be initialised before any worker module so that - // LeaderElectionService is available when workers call registerWorker() - // in their onModuleInit hooks. LeaderElectionModule.forRoot(), - // Issues #494/#495/#507 — admin RBAC + audit, job queue, runtime flags, - // guardian-derived policy state. AdminModule, JobsModule, FlagsModule, @@ -71,13 +51,11 @@ import { DatasetsModule } from "./datasets/datasets.module"; StatsModule, SorobanModule, RoutingModule, - MetricsModule, TreasuryModule, GovernanceModule, ], controllers: [], providers: [ - // Apply the IP-based throttle globally to every route { provide: APP_GUARD, useClass: ThrottlerGuard, diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index 7724cc8..0f6b695 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -100,6 +100,9 @@ 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). * diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 710004e..d82816b 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -117,6 +117,8 @@ export interface AppConfig { treasury: { address: string; }; + /** Chain name → JSON-RPC URL used to verify ERC-20 decimals and symbol. */ + evmRpcUrls: Record; onchainIntentsEnabled: boolean; intentRetentionDays: number; intentRetentionSweepMs: number; @@ -281,6 +283,17 @@ export interface AppConfig { /** Soroban RPC endpoints probed for quorum (majority must be healthy). */ rpcHealthUrls: string[]; }; + /** Public anonymised datasets (RFC 0001). Present so DatasetsModule typechecks. */ + datasets: { + enabled: boolean; + anonymize: boolean; + salt: string; + saltRotationHours: number; + saltRetentionWindows: number; + publicBucket: string; + storageKind: "local" | "memory"; + localDir: string; + }; } export default (): AppConfig => ({ @@ -302,6 +315,7 @@ export default (): AppConfig => ({ treasury: { address: process.env.TREASURY_ADDRESS ?? "", }, + evmRpcUrls: parseEvmRpcUrls(process.env.EVM_RPC_URLS), onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", intentRetentionDays: parseInt(process.env.INTENT_RETENTION_DAYS ?? "30", 10), intentRetentionSweepMs: parseInt(process.env.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), @@ -395,9 +409,35 @@ export default (): AppConfig => ({ .map((u) => u.trim()) .filter(Boolean), }, + datasets: { + enabled: (process.env.DATASETS_ENABLED ?? "false") === "true", + anonymize: (process.env.DATASETS_ANONYMIZE ?? "true") !== "false", + salt: process.env.DATASETS_SALT ?? "", + saltRotationHours: parseInt(process.env.DATASETS_SALT_ROTATION_HOURS ?? "24", 10), + saltRetentionWindows: parseInt(process.env.DATASETS_SALT_RETENTION_WINDOWS ?? "2", 10), + publicBucket: process.env.DATASETS_PUBLIC_BUCKET ?? "vortex-public-datasets", + storageKind: (process.env.DATASETS_STORAGE_KIND ?? "memory") as "local" | "memory", + localDir: process.env.DATASETS_LOCAL_DIR ?? "./data/datasets", + }, }); /** Parse `SHADOW_SAMPLE_RATE` into a probability, defaulting to full sampling. */ +/** `EVM_RPC_URLS` is a JSON object. Invalid JSON yields an empty map (verification fails closed). */ +export function parseEvmRpcUrls(raw: string | undefined): Record { + if (!raw || !raw.trim()) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const urls: Record = {}; + for (const [chain, url] of Object.entries(parsed)) { + if (typeof url === "string" && url.trim()) urls[chain.toLowerCase()] = url.trim(); + } + return urls; + } catch { + return {}; + } +} + function clampSampleRate(raw: string | undefined): number { if (raw === undefined || raw.trim() === "") return 1; const parsed = Number(raw); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 5f9a5d2..6630447 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -348,7 +348,6 @@ export const envValidationSchema = Joi.object({ 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), @@ -377,4 +376,17 @@ export const envValidationSchema = Joi.object({ // Comma-separated Soroban RPC URLs for the RPC-quorum readiness check. // Defaults to SOROBAN_RPC_URL. SOROBAN_RPC_HEALTH_URLS: Joi.string().allow("").default(""), + // Already read by configuration.ts and documented in .env.example. + HORIZON_URL: Joi.string().uri().default("https://horizon-testnet.stellar.org"), + TREASURY_ADDRESS: Joi.string().allow("").default(""), + /// JSON object of chain name → HTTPS JSON-RPC URL for ERC-20 metadata checks. + EVM_RPC_URLS: Joi.string().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(0).default(2), + DATASETS_PUBLIC_BUCKET: Joi.string().default("vortex-public-datasets"), + DATASETS_STORAGE_KIND: Joi.string().valid("local", "memory").default("memory"), + DATASETS_LOCAL_DIR: Joi.string().default("./data/datasets"), }); diff --git a/src/governance/governance.module.ts b/src/governance/governance.module.ts index ae876f5..106f0b7 100644 --- a/src/governance/governance.module.ts +++ b/src/governance/governance.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { ProtocolParamsService } from "./params.service"; import { ParamsController } from "./params.controller"; import { SorobanModule } from "../soroban/soroban.module"; @@ -14,7 +14,7 @@ import { GuardianService } from "./guardian.service"; * inject it to snapshot parameters at intent-creation time. */ @Module({ - imports: [SorobanModule], + imports: [forwardRef(() => SorobanModule)], controllers: [ParamsController, GuardianController], providers: [ProtocolParamsService, GuardianService], exports: [ProtocolParamsService, GuardianService], diff --git a/src/health/health-indicator.registry.ts b/src/health/health-indicator.registry.ts index 1ae6343..f0a24cf 100644 --- a/src/health/health-indicator.registry.ts +++ b/src/health/health-indicator.registry.ts @@ -21,7 +21,7 @@ export interface HealthIndicator { check(): Promise; } -interface CachedResult extends IndicatorResult { +export interface CachedResult extends IndicatorResult { critical: boolean; checkedAt: string; durationMs: number; diff --git a/src/intents/intents-sweeper.manual-trigger.spec.ts b/src/intents/intents-sweeper.manual-trigger.spec.ts index 7ebbb73..bf811ca 100644 --- a/src/intents/intents-sweeper.manual-trigger.spec.ts +++ b/src/intents/intents-sweeper.manual-trigger.spec.ts @@ -52,8 +52,8 @@ describe("IntentsSweeperService — manual sweep trigger (#269)", () => { solverRegistry, metricsService, killSwitch, + noopLeaderElection(), ); - return new IntentsSweeperService(intentsService, gateway, solversService, solverRegistry, metricsService, noopLeaderElection()); } afterEach(() => jest.restoreAllMocks()); diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index eb06738..f8920e8 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -9,6 +9,7 @@ import { InMemoryIntentsRepository } from "./intents.repository"; import { logger } from "../common/logger"; import { buildWsAuthMessage } from "../common/stellar-signature"; import { ProtocolParamsService } from "../governance/params.service"; +import { IntentCapabilityIndex } from "./solver-intent-matcher"; jest.mock("../common/logger", () => ({ logger: { @@ -134,7 +135,7 @@ describe("IntentsGateway heartbeat", () => { jest.clearAllMocks(); intentsService = makeIntentsService(); solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService); + gateway = new IntentsGateway(intentsService, solversService, new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -231,10 +232,16 @@ 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" })); + expect(client.send).toHaveBeenCalledWith( + JSON.stringify({ type: "auth_ok", method: "signature" }), + expect.any(Function), + ); 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" })); + expect(client.send).toHaveBeenLastCalledWith( + JSON.stringify({ type: "auth_error", reason: "invalid solver signature" }), + expect.any(Function), + ); }); }); @@ -250,7 +257,7 @@ describe("IntentsGateway logging", () => { jest.clearAllMocks(); intentsService = makeIntentsService(); solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService); + gateway = new IntentsGateway(intentsService, solversService, new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -259,7 +266,7 @@ describe("IntentsGateway logging", () => { }); it("logs heartbeat started on construction", () => { - expect(logger.info).toHaveBeenCalledWith("ws heartbeat started"); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("ws heartbeat started")); }); it("logs connection with subscriber count", () => { @@ -310,7 +317,7 @@ describe("IntentsGateway — chain subscription filtering (#257)", () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService()); + gateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -467,7 +474,7 @@ describe("IntentsGateway — event replay (#258)", () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService()); + gateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -504,7 +511,7 @@ describe("IntentsGateway — event replay (#258)", () => { it("returns replay_too_old when fromSeq has been evicted from the buffer", async () => { // Use a tiny ring buffer (capacity 2) to force eviction - const tinyGateway = new IntentsGateway(intentsService, makeSolversService()); + const tinyGateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); // @ts-expect-error – accessing private field for test setup tinyGateway.ringBuffer["capacity"] = 2; diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index 0f7a29a..b6faeec 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -294,9 +294,7 @@ export class IntentsGateway this.alive.set(client, true); this.metricsService?.incWsConnection(); - client.on("message", (raw) => { - void this.handleMessage(client, raw); - }); + client.on("message", (raw) => this.handleMessage(client, raw)); client.on("pong", () => { this.alive.set(client, true); @@ -724,7 +722,9 @@ export class IntentsGateway // Non-fatal — solver can fall back to GET /solvers/:address/eligible-intents. } - logger.info(`ws solver auth ok: address=${solver} chains=${solverRecord.supportedChains.join(",")} tokens=${solverRecord.supportedTokens.join(",")}`); + logger.info( + `ws solver auth ok: address=${solver} chains=${(solverRecord.supportedChains ?? []).join(",")} tokens=${(solverRecord.supportedTokens ?? []).join(",")}`, + ); } /** diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 378b82f..fa83ec7 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -17,21 +17,23 @@ import { SorobanModule } from "../soroban/soroban.module"; import { AppConfig } from "../config/configuration"; import { PrismaService } from "../prisma/prisma.service"; import { GovernanceModule } from "../governance/governance.module"; +import { TokenListPublisher } from "../tokens/admin-tokens.service"; + +/** Side-effect provider: points token-list events at the WebSocket gateway. */ +export const TOKEN_LIST_WS_BINDING = Symbol("TOKEN_LIST_WS_BINDING"); @Module({ // Both SolversModule and SorobanModule import IntentsModule back, so both // edges of each cycle must be deferred — a bare import resolves to `undefined` // when the peer module is still mid-initialization (AppModule reaches // SorobanModule through HealthModule before IntentsModule has finished). - // `forwardRef` on the SorobanModule import mirrors the one in SorobanModule: - // the two modules need each other (ShadowService here, IntentsService there). imports: [ forwardRef(() => SolversModule), RoutingModule, TokensModule, forwardRef(() => SorobanModule), + GovernanceModule, ], - imports: [forwardRef(() => SolversModule), RoutingModule, TokensModule, SorobanModule, GovernanceModule], controllers: [IntentsController], providers: [ // Select the persistence adapter based on INTENTS_PERSISTENCE env var. @@ -55,8 +57,15 @@ import { GovernanceModule } from "../governance/governance.module"; backplaneHealthIndicator, IntentsSweeperService, IntentsMaintenanceJobs, - // Note: EventIngestionService is provided by SorobanModule (imported above) - // and exported from there — no re-declaration needed here. + { + provide: TOKEN_LIST_WS_BINDING, + inject: [TokenListPublisher, IntentsGateway], + useFactory: (publisher: TokenListPublisher, gateway: IntentsGateway) => { + publisher.publish = (event) => + gateway.broadcast(event as unknown as { type: string; [key: string]: unknown }); + return publisher; + }, + }, ], exports: [IntentsService, IntentsGateway, IntentCapabilityIndex], }) diff --git a/src/intents/intents.service.shadow.spec.ts b/src/intents/intents.service.shadow.spec.ts index 2d289f0..a0bb472 100644 --- a/src/intents/intents.service.shadow.spec.ts +++ b/src/intents/intents.service.shadow.spec.ts @@ -7,6 +7,7 @@ import { ShadowService, type ShadowObservationRequest } from "../soroban/shadow. import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; import { InMemoryIntentsRepository } from "./intents.repository"; +import { ProtocolParamsService } from "../governance/params.service"; /** * Wiring tests for the shadow-mode divergence monitor at its real call sites @@ -69,11 +70,21 @@ interface Harness { function makeService(options: { accepts?: boolean } = {}): Harness { const shadow = fakeShadowService(options.accepts ?? true); const metrics = fakeMetricsService(); + const protocolParams = { + snapshotForChain: jest.fn().mockReturnValue({ + version: 0, + feeBps: 30, + deadlineSeconds: 1800, + fillWindowSeconds: 600, + capturedAt: new Date().toISOString(), + }), + } as unknown as ProtocolParamsService; const service = new IntentsService( new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), fakePrismaService(), + protocolParams, shadow as unknown as ShadowService, metrics, ); diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index a9867fe..a6b8620 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -372,10 +372,10 @@ describe("IntentsService", () => { const stellarTxService = fakeStellarTxService(); const svc = makeService({ onchainIntentsEnabled: false }, stellarTxService); - const intent = await service.create(validCreateData()); + const intent = await svc.create(validCreateData()); expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); - expect(await service.get(intent.intentId)).toEqual(intent); + expect(await svc.get(intent.intentId)).toEqual(intent); }); it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => { @@ -387,7 +387,7 @@ describe("IntentsService", () => { ); const data = validCreateData(); - const intent = await service.create(data); + const intent = await svc.create(data); expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1); const call = stellarTxService.invokeContract.mock.calls[0][0]; @@ -407,17 +407,16 @@ describe("IntentsService", () => { state: "", createdAt: 0, deadline: 0, + paramsVersion: 0, }).sort(), ); - expect(await service.get(intent.intentId)).toBeDefined(); + 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; - const svc = makeService({ onchainIntentsEnabled: true }, stellarTxService); - const before = (await svc.getAll()).length; await expect(service.create(validCreateData())).rejects.toMatchObject({ message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"), @@ -433,10 +432,10 @@ describe("IntentsService", () => { { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, stellarTxService, ); - const before = (await service.getAll()).length; + const before = (await svc.getAll()).length; - await expect(service.create(validCreateData())).rejects.toThrow(/settlement contract/i); - expect(await service.getAll()).toHaveLength(before); + await expect(svc.create(validCreateData())).rejects.toThrow(/settlement contract/i); + expect(await svc.getAll()).toHaveLength(before); }); }); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index c8222ed..3c77398 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -109,6 +109,7 @@ export class IntentsService { private readonly configService: ConfigService, private readonly stellarTxService: StellarTxService, private readonly prisma: PrismaService, + private readonly protocolParamsService: ProtocolParamsService, /** * Shadow-mode divergence monitor (issue #401). * @@ -128,7 +129,6 @@ export class IntentsService { * this is always present. */ @Optional() private readonly metricsService?: MetricsService, - private readonly protocolParamsService: ProtocolParamsService, @Optional() private readonly flags?: FeatureFlagService, ) {} @@ -533,9 +533,6 @@ export class IntentsService { nativeToScVal(intent.deadline, { type: "u64" }), ]), ); - const snapshot = this.protocolParamsService.snapshotForChain(intent.srcChain); - const fillWindow = snapshot.fillWindowSeconds; - return this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); } /** @@ -654,6 +651,7 @@ export class IntentsService { // corrupt, so skip the simulation rather than encoding a null address — // the sweep loop already logs that case loudly. if (subject?.solver) { + const solver = subject.solver; this.reportShadow( "slash", subject.intentId, @@ -661,9 +659,9 @@ export class IntentsService { "slash_intent", this.safeArgs(() => [ nativeToScVal(subject.intentId, { type: "string" }), - new Address(subject.solver).toScVal(), - nativeToScVal(patch.slashReason, { type: "string" }), - nativeToScVal(patch.slashedAt, { type: "u64" }), + new Address(solver).toScVal(), + nativeToScVal(patch.slashReason ?? "", { type: "string" }), + nativeToScVal(patch.slashedAt ?? 0, { type: "u64" }), ]), ); } diff --git a/src/intents/solver-intent-matcher.ts b/src/intents/solver-intent-matcher.ts index a0964c5..1cd5601 100644 --- a/src/intents/solver-intent-matcher.ts +++ b/src/intents/solver-intent-matcher.ts @@ -40,20 +40,21 @@ export interface SolverMatchPredicate { * it is cheap to evaluate (no object allocations per intent check). */ export function buildMatchPredicate(solver: SolverRecord): SolverMatchPredicate { - const chainSet = new Set(solver.supportedChains); - const tokenSet = new Set( - solver.supportedTokens.map((t) => t.toLowerCase()), - ); - const hasBond = BigInt(solver.bondAmount) > 0n; + const chains = solver.supportedChains ?? []; + const tokens = solver.supportedTokens ?? []; + const chainSet = new Set(chains); + const tokenSet = new Set(tokens.map((t) => t.toLowerCase())); + const hasBond = BigInt(solver.bondAmount ?? "0") > 0n; return { solverAddress: solver.address, - supportedChains: [...solver.supportedChains], - supportedTokens: [...solver.supportedTokens], - bondAmount: solver.bondAmount, + supportedChains: [...chains], + supportedTokens: [...tokens], + bondAmount: solver.bondAmount ?? "0", matches(intent: Intent): boolean { if (!hasBond) return false; - if (!chainSet.has(intent.srcChain)) return false; + if (chains.length > 0 && !chainSet.has(intent.srcChain)) return false; + if (tokens.length === 0) return true; const symbol = typeof intent.srcToken === "object" && intent.srcToken !== null ? // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/intents/ws/connection-state.ts b/src/intents/ws/connection-state.ts index 0973ccd..17a6a75 100644 --- a/src/intents/ws/connection-state.ts +++ b/src/intents/ws/connection-state.ts @@ -1,4 +1,4 @@ -import type { WebSocket } from "ws"; +import { WebSocket } from "ws"; /** Classic token bucket: `ratePerSec` sustained, up to `burst` at once. */ export class TokenBucket { @@ -83,8 +83,8 @@ export class ConnectionState { } send(payload: string): OutboundResult { - if (this.closed || this.socket.readyState !== this.socket.OPEN) return "sent"; - if (this.queue.length === 0 && this.socket.bufferedAmount < this.limits.bufferBytes) { + if (this.closed || this.socket.readyState !== WebSocket.OPEN) return "sent"; + if (this.queue.length === 0 && this.bufferedAmount() < this.limits.bufferBytes) { this.write(payload); return "sent"; } @@ -104,6 +104,12 @@ export class ConnectionState { this.queue.length = 0; } + /** `bufferedAmount` is 0 until the socket reports bytes waiting in the kernel buffer. */ + private bufferedAmount(): number { + const buffered = this.socket.bufferedAmount; + return typeof buffered === "number" ? buffered : 0; + } + private write(payload: string) { this.socket.send(payload, () => this.flush()); } @@ -112,8 +118,8 @@ export class ConnectionState { while ( !this.closed && this.queue.length > 0 && - this.socket.readyState === this.socket.OPEN && - this.socket.bufferedAmount < this.limits.bufferBytes + this.socket.readyState === WebSocket.OPEN && + this.bufferedAmount() < this.limits.bufferBytes ) { this.write(this.queue.shift()!); } diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 1898b82..94394ec 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -6,6 +6,7 @@ import { Get, NotFoundException, Param, + Patch, Post, Query, } from "@nestjs/common"; @@ -18,18 +19,11 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IntentsService } from "../intents/intents.service"; -import { buildDisputeMessage, buildRegisterMessage, buildUpdateSolverMessage, verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; -import { SolversService, LeaderboardWindow, solverSupports } from "./solvers.service"; -import { ListIntentsDto } from "../intents/dto/list-intents.dto"; -import { ApiNotFoundResponse, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; import { ConfigService } from "@nestjs/config"; import { AppConfig } from "../config/configuration"; import { isCanaryIntent } from "../common/canary"; import { IntentsService } from "../intents/intents.service"; import { IntentCapabilityIndex } from "../intents/solver-intent-matcher"; -import { buildDisputeMessage, verifyStellarSignature, buildSolverStatusMessage, buildRegisterMessage } from "../common/stellar-signature"; -import { SUPPORTED_CHAINS, SupportedChain } from "../intents/intents.types"; import { buildDisputeMessage, buildRegisterMessage, @@ -38,7 +32,6 @@ import { verifyStellarSignature, } from "../common/stellar-signature"; import { SolversService, LeaderboardWindow } from "./solvers.service"; -import { SolverRecord } from "./solvers.types"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverDto } from "./dto/update-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; @@ -50,45 +43,6 @@ const WINDOW_SECONDS: Record, number> = { "30d": 30 * 24 * 60 * 60, }; -/** - * Whether `solver` is able to work `chain`/`tokenSymbol` at all. - * - * A solver with no declared chains or tokens is treated as unrestricted — that - * matches registration defaults, where the fields are optional declarations - * of focus rather than a hard allow-list, and it keeps existing solvers - * eligible for intents created before the fields existed. - * - * Matching is case-insensitive on the token symbol because registries and - * user-supplied intent payloads disagree on casing (e.g. "USDC" vs "usdc"). - */ -function solverSupports( - solver: SolverRecord, - chain: string, - tokenSymbol: string, -): boolean { - if (solver.supportedChains.length > 0) { - const supportsChain = solver.supportedChains.some( - (c: SupportedChain) => c.toLowerCase() === String(chain).toLowerCase(), - ); - if (!supportsChain) return false; - } - - if (solver.supportedTokens.length > 0) { - const needle = String(tokenSymbol).toLowerCase(); - const supportsToken = solver.supportedTokens.some( - (t: string) => String(t).toLowerCase() === needle, - ); - if (!supportsToken) return false; - } - - return true; -} - -/** Guard against chain values that are not part of the supported set. */ -function isSupportedChain(value: string): value is SupportedChain { - return (SUPPORTED_CHAINS as readonly string[]).includes(value); -} - @ApiTags("solvers") @Controller("api/v1/solvers") export class SolversController { @@ -234,12 +188,6 @@ export class SolversController { // 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 open = await this.intentsService.getByState("open"); - const eligible = open.filter( - (intent) => - isSupportedChain(intent.srcChain) && - solverSupports(solver, intent.srcChain, intent.srcToken.symbol), - ); const limit = Math.min(dto.limit ?? 20, 100); const offset = dto.offset ?? 0; @@ -270,6 +218,14 @@ export class SolversController { * stripped by the DTO whitelist. */ @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.", + }) @ApiOkResponse({ description: "Updated solver record" }) @ApiBadRequestResponse({ description: "Invalid update body" }) @ApiUnauthorizedResponse({ description: "Missing or invalid signature" }) @@ -415,43 +371,6 @@ export class SolversController { 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") { diff --git a/src/soroban/event-ingestion.service.ts b/src/soroban/event-ingestion.service.ts index 0a33ba8..d806e58 100644 --- a/src/soroban/event-ingestion.service.ts +++ b/src/soroban/event-ingestion.service.ts @@ -61,6 +61,7 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { private readonly sorobanService: SorobanService, private readonly configService: ConfigService, private readonly solversService: SolversService, + private readonly leaderElection: LeaderElectionService, /** * SLO emitters for the on-chain dashboard. `@Optional()` so the unit tests * that construct this service directly do not need a metrics registry; @@ -74,7 +75,6 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { * where the intent service is reached through a `forwardRef`. */ @Optional() private readonly intentsService?: IntentsService, - private readonly leaderElection: LeaderElectionService, ) {} onModuleInit() { diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 6e883f5..93c3f59 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -5,6 +5,7 @@ import { AppConfig } from "../config/configuration"; import { SignerService } from "./signer.service"; import { SorobanService } from "./soroban.service"; import { findSensitiveKeyMaterial } from "./redaction"; +import { LocalKeypairSigner } from "./signers/local-keypair.signer"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { @@ -20,20 +21,28 @@ function fakeSorobanService(startingSequence = "100") { } as unknown as jest.Mocked; } +function makeSignerService( + secret: string, + network: AppConfig["stellar"]["network"] = "testnet", + soroban: jest.Mocked = fakeSorobanService(), +): SignerService { + return new SignerService(new LocalKeypairSigner(configWith(secret, network)), soroban); +} + describe("SignerService", () => { it("reports unconfigured when no secret is set", () => { - const service = new SignerService(configWith(""), fakeSorobanService()); + const service = makeSignerService(""); expect(service.isConfigured()).toBe(false); }); it("throws a clear, secret-free error when signing without a configured key", () => { - const service = new SignerService(configWith(""), fakeSorobanService()); - expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNER_SECRET_KEY/); + const service = makeSignerService(""); + expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNING_KEY/); }); it("derives the public key from the configured secret", () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); expect(service.isConfigured()).toBe(true); expect(service.getPublicKey()).toBe(keypair.publicKey()); @@ -41,14 +50,14 @@ describe("SignerService", () => { it("maps network config to the right passphrase", () => { const soroban = fakeSorobanService(); - expect(new SignerService(configWith("", "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); - expect(new SignerService(configWith("", "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); - expect(new SignerService(configWith("", "mainnet"), soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); + expect(makeSignerService("", "testnet", soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); + expect(makeSignerService("", "futurenet", soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); + expect(makeSignerService("", "mainnet", soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); }); - it("signs a transaction with the configured key", () => { + it("signs a transaction with the configured key", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); const account = new Account(keypair.publicKey(), "1"); const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) @@ -57,13 +66,13 @@ describe("SignerService", () => { .build(); expect(tx.signatures).toHaveLength(0); - const signed = service.sign(tx); + 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(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); const secret = keypair.secret(); expect(String(service)).not.toContain(secret); @@ -88,7 +97,7 @@ describe("SignerService", () => { it("fetches the starting sequence once and increments it locally", async () => { const keypair = Keypair.random(); const soroban = fakeSorobanService("100"); - const service = new SignerService(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); const first = await service.withNextSequence(async (sequence) => sequence); const second = await service.withNextSequence(async (sequence) => sequence); @@ -101,7 +110,7 @@ describe("SignerService", () => { 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(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); const results = await Promise.all( Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)), @@ -114,7 +123,7 @@ describe("SignerService", () => { it("runs callers strictly one at a time, in call order", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0")); + const service = makeSignerService(keypair.secret(), "testnet", fakeSorobanService("0")); const order: number[] = []; const slow = service.withNextSequence(async () => { @@ -132,7 +141,7 @@ describe("SignerService", () => { 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(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); await expect( service.withNextSequence(async () => { @@ -147,7 +156,7 @@ describe("SignerService", () => { it("does not let a failed caller block callers queued behind it", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0")); + const service = makeSignerService(keypair.secret(), "testnet", fakeSorobanService("0")); const failing = service.withNextSequence(async () => { throw new Error("boom"); diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index 043fafd..6a255de 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -9,6 +9,7 @@ function makeConfigService( const stellar: AppConfig["stellar"] = { network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", settlementContractId: "", solverRegistryContractId: "", signerSecretKey: "", @@ -70,6 +71,25 @@ function makeConfigService( slowConsumerPolicy: "drop_oldest", }, authJwtSecret: "", + treasury: { address: "" }, + evmRpcUrls: {}, + shadow: { + enabled: false, + sampleRate: 1, + queueMax: 256, + concurrency: 1, + sourceAccount: "", + }, + datasets: { + enabled: false, + anonymize: true, + salt: "", + saltRotationHours: 24, + saltRetentionWindows: 2, + publicBucket: "vortex-public-datasets", + storageKind: "memory", + localDir: "./data/datasets", + }, health: { roles: ["api", "ws", "worker"], checkIntervalMs: 5000, diff --git a/src/soroban/soroban.controller.spec.ts b/src/soroban/soroban.controller.spec.ts index af2f381..157b021 100644 --- a/src/soroban/soroban.controller.spec.ts +++ b/src/soroban/soroban.controller.spec.ts @@ -112,9 +112,6 @@ describe("SorobanController", () => { describe("getAccount", () => { const PUBLIC_KEY = "GA6NGB7335VYC4CEHUN6KZD2NNESU36BKCL5LZCTTY6ICW5ZVII67FF4"; - // Real strkeys (valid CRC16). A well-formed-looking "G…" string with a bad - // checksum is rejected by the controller, so fixtures must be genuine. - const PUBLIC_KEY = "GAMS2CGT4CPVYB5LSZV3FAOYFJK67574RS5HASJNTNS7WEUO3CN6ADW4"; const OTHER_KEY = "GCGMQIBI2B64NO4JI5IRXUOKFQYFUXBRJUUQBOHJQZA34JOKFU3W2WVK"; it("passes the publicKey path param through to sorobanService.getAccount", async () => { @@ -129,8 +126,6 @@ describe("SorobanController", () => { }); it("passes a different publicKey correctly", async () => { - const anotherKey = "GBCI24BNYGGIRDE4PCUD6PJAQINUVQPIUJCBJT4HTZZONEXNVVDIYVAC"; - mockSorobanService.getAccount.mockResolvedValueOnce({ id: anotherKey }); mockSorobanService.getAccount.mockResolvedValueOnce({ id: OTHER_KEY }); await controller.getAccount(OTHER_KEY); diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index f8b97d0..20c77e9 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,5 +1,4 @@ import { forwardRef, Module } from "@nestjs/common"; -import { Module, forwardRef } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { EventIngestionService } from "./event-ingestion.service"; import { ShadowController } from "./shadow.controller"; @@ -14,32 +13,17 @@ import { SolverRegistryEventsService } from "./events/solver-registry-events.ser import { SIGNER_TOKEN, signerFactory } from "./signers/signer.factory"; import { SolversModule } from "../solvers/solvers.module"; import { MetricsService } from "../metrics/metrics.service"; -import { AppConfig } from "../config/configuration"; -import { IntentsModule } from "../intents/intents.module"; -import { SolversModule } from "../solvers/solvers.module"; import { IntentsModule } from "../intents/intents.module"; // MetricsModule is @Global() and registered in AppModule, so the MetricsService // that ShadowService emits its counters through needs no import here. @Module({ - // SorobanModule <-> SolversModule <-> IntentsModule (which imports this - // module) form a CommonJS cycle. SolversModule must be resolved lazily so - // that evaluating this file never triggers IntentsModule's module decorator - // while SorobanModule is still partially initialised. - // eslint-disable-next-line @typescript-eslint/no-var-requires - imports: [forwardRef(() => require("../solvers/solvers.module").SolversModule)], - imports: [forwardRef(() => SolversModule)], // IntentsModule → SorobanModule (IntentsService submits settlement writes) // and SorobanModule → IntentsModule (EventIngestionService reconciles // intents from on-chain events). The cycle is broken with forwardRef. - // SolversModule supplies SolversService to EventIngestionService and, via - // IntentsModule, also participates in the cycle — so it is deferred too. + // SolversModule supplies SolversService to EventIngestionService and also + // participates in the cycle via IntentsModule, so it is deferred too. imports: [forwardRef(() => IntentsModule), forwardRef(() => SolversModule)], - controllers: [SorobanController], - // `forwardRef` is required on both sides: EventIngestionService reads an - // Intent back to date its confirmation metric, so SorobanModule needs - // IntentsModule, and IntentsModule already needs ShadowService from here. - imports: [forwardRef(() => IntentsModule), SolversModule], controllers: [SorobanController, ShadowController], providers: [ SorobanService, diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index e5b3b6a..8083cfe 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -35,8 +35,12 @@ export class SorobanService { * `closeTime` here is what makes `vortex_event_ingestion_lag_seconds` a real * measurement rather than a guess. */ - getLedger(sequence: number) { - return this.server.getLedger(sequence); + getLedger(sequence: number): Promise<{ header?: { closeTime?: string | number } }> { + return ( + this.server as unknown as { + getLedger: (seq: number) => Promise<{ header?: { closeTime?: string | number } }>; + } + ).getLedger(sequence); } getEvents(request: SorobanRpc.Server.GetEventsRequest) { diff --git a/src/soroban/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts index 8818247..1ac92d6 100644 --- a/src/soroban/stellar-tx.service.spec.ts +++ b/src/soroban/stellar-tx.service.spec.ts @@ -13,6 +13,8 @@ import { import { ConfigService } from "@nestjs/config"; import { StellarTxService, type SimulateContractParams } from "./stellar-tx.service"; import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; +import { TxConfirmationService } from "./tx-confirmation.service"; import { AppConfig } from "../config/configuration"; import { KillSwitchService } from "../killswitch/killswitch.service"; @@ -70,7 +72,11 @@ const CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; function validityWindowSeconds(transaction: Transaction): number { const bounds = transaction.timeBounds; if (!bounds) throw new Error("expected the simulation envelope to carry a validity window"); - return Number(bounds.maxTime) - Number(bounds.minTime); + const min = Number(bounds.minTime); + const max = Number(bounds.maxTime); + // Current stellar-sdk setTimeout() stores minTime=0 and maxTime=now+seconds. + const start = min === 0 ? Math.floor(Date.now() / 1000) : min; + return max - start; } describe("StellarTxService", () => { @@ -92,7 +98,10 @@ describe("StellarTxService", () => { killSwitch = { evaluateTarget: jest.fn().mockReturnValue(notPaused) }; service = new StellarTxService( sorobanService as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, configService as unknown as ConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); }); @@ -163,7 +172,10 @@ describe("StellarTxService", () => { const dryRunService = new StellarTxService( sorobanService as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, dryRunConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); @@ -189,9 +201,19 @@ describe("StellarTxService", () => { }), } as unknown as ConfigService; + const signer = { + withNextSequence: jest.fn(async () => { + throw new Error("live submission attempted"); + }), + getPublicKey: () => "GTEST", + getNetworkPassphrase: () => Networks.TESTNET, + }; const liveService = new StellarTxService( sorobanService as unknown as SorobanService, + signer as unknown as SignerService, + {} as TxConfirmationService, liveConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); @@ -201,7 +223,8 @@ describe("StellarTxService", () => { method: "create_intent", args: [], }), - ).rejects.toThrow(/not yet implemented/); + ).rejects.toThrow(/live submission attempted/); + expect(signer.withNextSequence).toHaveBeenCalled(); }); }); @@ -243,7 +266,12 @@ describe("StellarTxService", () => { } as unknown as ConfigService; return { - service: new StellarTxService(soroban as unknown as SorobanService, configService), + service: new StellarTxService( + soroban as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, + configService, + ), soroban, }; } @@ -318,7 +346,7 @@ describe("StellarTxService", () => { it("classifies a hard failure as a contract error", async () => { const { service, soroban } = buildShadowService(); soroban.simulateTransaction.mockResolvedValue( - simulationError("HostError: Error(WasmVm, InvalidAction) missing export"), + simulationError("HostError: missing export"), ); const result = await service.simulateContract(params()); @@ -365,7 +393,7 @@ describe("StellarTxService", () => { await service.simulateContract(params()); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).source.sequenceNumber()).toBe("42"); + expect(String((submitted as Transaction).sequence)).toBe("43"); expect(soroban.getLatestLedger).not.toHaveBeenCalled(); }); @@ -378,7 +406,7 @@ describe("StellarTxService", () => { const [submitted] = soroban.simulateTransaction.mock.calls[0]; // 500 (latest closed) + 1: the next sequence the account would hold. - expect((submitted as Transaction).source.sequenceNumber()).toBe("501"); + expect(String((submitted as Transaction).sequence)).toBe("502"); }); it("falls back to sequence 0 when neither the account nor the ledger can be read", async () => { @@ -391,7 +419,7 @@ describe("StellarTxService", () => { expect(result.outcome).toBe("ok"); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).source.sequenceNumber()).toBe("0"); + expect(String((submitted as Transaction).sequence)).toBe("1"); }); it("builds a single, well-formed host-function operation for the named method", async () => { @@ -402,12 +430,8 @@ describe("StellarTxService", () => { expect(result.outcome).toBe("ok"); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - const envelope = (submitted as Transaction).toEnvelope(); - expect(envelope.operations()).toHaveLength(1); - // Round-trips through XDR, so the host function and every ScVal the - // monitor built are structurally valid — which is the whole reason the - // monitor cannot blame a malformed envelope for a "divergence". - expect(() => envelope.toXDR()).not.toThrow(); + expect((submitted as Transaction).operations).toHaveLength(1); + expect(() => (submitted as Transaction).toXDR()).not.toThrow(); }); it("sizes the ledger validity window from the worst-case shadow queue drain", async () => { diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index 7ff3d4e..843adbe 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -38,7 +38,6 @@ import { SorobanDataBuilder, nativeToScVal, Networks, - Operation, SorobanRpc, Transaction, TransactionBuilder, @@ -162,7 +161,7 @@ export class StellarTxService { private readonly confirmationService: TxConfirmationService, configService: ConfigService, @Optional() private readonly metricsService?: MetricsService, - private readonly killSwitch: KillSwitchService, + @Optional() private readonly killSwitch?: KillSwitchService, @Optional() private readonly flags?: FeatureFlagService, ) { this.feePercentile = configService.get("stellar.feePercentile", { infer: true }); @@ -475,7 +474,7 @@ export class StellarTxService { */ private assertOnChainWriteAllowed(method: string): void { try { - assertNotPaused(this.killSwitch, { + assertNotPaused(this.killSwitch!, { // Deliberately the protocol chain, not `stellar.network`. Switch scopes // are addressed with the chain an intent names ("stellar"); the network // ("testnet"/"mainnet") selects a Soroban endpoint and would never match @@ -610,18 +609,14 @@ export class StellarTxService { }) .addOperation( Operation.invokeHostFunction({ - func: xdr.HostFunctionType.hostFunctionTypeInvokeContract, - args: [ - contract.toScAddress(), - // The method name is a symbol in the Soroban ABI, not a string. - nativeToScVal(params.method, { type: "symbol" }), - params.args, - // Token the call is denominated in. `native` is XLM; the settlement - // contract's own token is a distinct `ScAddress` entry point. The - // value is irrelevant to a simulation, but it must be a well-formed - // ScVal for the envelope to decode. - nativeToScVal("native", { type: "symbol" }), - ], + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: contract.toScAddress(), + functionName: Buffer.from(params.method), + args: [...params.args, nativeToScVal("native", { type: "symbol" })], + }), + ), + auth: [], }), ) .setTimeout(this.simulationTimeoutSeconds) diff --git a/src/soroban/tx-confirmation.service.ts b/src/soroban/tx-confirmation.service.ts index e8df7ad..1154612 100644 --- a/src/soroban/tx-confirmation.service.ts +++ b/src/soroban/tx-confirmation.service.ts @@ -88,7 +88,7 @@ export class TxConfirmationService { } // FAILED - const errorDetail = (response as { resultXdr?: string }).resultXdr ?? "unknown"; + const errorDetail = (response as unknown as { resultXdr?: string }).resultXdr ?? "unknown"; this.logger.warn( `[tx-confirmation] FAILED hash=${hash} durationMs=${durationMs} detail=${errorDetail}`, ); 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/in-memory-tokens.repository.ts b/src/tokens/in-memory-tokens.repository.ts index fb97373..6ab148d 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, TokenAssetKind, 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) => ({ @@ -13,6 +14,8 @@ export class InMemoryTokensRepository implements ITokensRepository { chain: chain as SupportedChain, priceUsd: token.priceUSD, isStellar: false, + status: "active" as TokenStatus, + assetKind: "evm" as TokenAssetKind, })), ), ...STELLAR_TOKENS.map((token) => ({ @@ -23,6 +26,8 @@ export class InMemoryTokensRepository implements ITokensRepository { chain: "stellar" as const, priceUsd: token.priceUSD, isStellar: true, + status: "active" as TokenStatus, + assetKind: "stellar-sac" as TokenAssetKind, })), ]; @@ -38,13 +43,40 @@ export class InMemoryTokensRepository implements ITokensRepository { } findByAddressAndChain(address: string, chain: SupportedChain | string): TokenRecord | undefined { + const match = this.findIndex(address, chain); + return match >= 0 ? { ...this.records[match] } : undefined; + } + + async save(record: TokenRecord): Promise { + const stored: TokenRecord = { ...record, status: record.status ?? "active" }; + const index = this.findIndex(stored.address, stored.chain); + if (index >= 0) this.records[index] = stored; + else this.records.push(stored); + this.generation += 1; + return { ...stored }; + } + + async setStatus( + address: string, + chain: SupportedChain | string, + status: TokenStatus, + ): Promise { + const index = this.findIndex(address, chain); + if (index < 0) return undefined; + this.records[index] = { ...this.records[index], status }; + this.generation += 1; + return { ...this.records[index] }; + } + + cacheGeneration(): number { + return this.generation; + } + + private findIndex(address: string, chain: SupportedChain | string): number { const normalizedAddress = address.trim().toLowerCase(); const chainName = String(chain).toLowerCase(); - const match = this.records.find( - (record) => - record.address.toLowerCase() === normalizedAddress && record.chain === chainName, + return this.records.findIndex( (record) => record.address.toLowerCase() === normalizedAddress && record.chain === chainName, ); - return match ? { ...match } : undefined; } } 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/tokens.service.ts b/src/tokens/tokens.service.ts index eb27c5c..56e0c44 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import { SUPPORTED_TOKENS, StellarToken } from "./tokens.data"; -import { SUPPORTED_TOKENS, STELLAR_TOKENS, StellarToken } from "./tokens.data"; import { SupportedChain } from "../intents/intents.types"; import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord } from "./tokens.repository"; @@ -55,19 +54,11 @@ export class TokensService { ) {} /** - * Look up a source token by chain + address/contract. - * - * For Stellar source tokens the `address` parameter is the contract ID. - * For EVM chains it is the checksummed hex address. - * - * Returns `undefined` when no match is found — callers decide how to handle - * the "unknown token" case (e.g. fall back to a default priceUSD). - * - * @param chain The source chain (stellar | ethereum | base | …) - * @param address Token contract/address string + * Look up a source token by chain + address/contract, including paused and + * delisted rows. Existing intents keep resolving after a soft delist. */ async resolveSrcToken(chain: SupportedChain, address: string): Promise { - const token = await this.repo.findByAddressAndChain(address, chain); + const token = await Promise.resolve(this.repo.findByAddressAndChain(address, chain)); if (!token) return undefined; return { kind: "src", @@ -80,13 +71,9 @@ export class TokensService { }; } - /** - * Look up a Stellar destination token by contract ID. - * - * Returns `undefined` when no match is found. - */ + /** Look up a Stellar destination token by contract ID, including delisted rows. */ async resolveDstToken(contract: string): Promise { - const token = await this.repo.findByAddressAndChain(contract, "stellar"); + const token = await Promise.resolve(this.repo.findByAddressAndChain(contract, "stellar")); if (!token) return undefined; return { kind: "dst", @@ -99,17 +86,13 @@ export class TokensService { } /** - * Like {@link resolveSrcToken} but throws a `BadRequestException` instead of - * 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. + * Like {@link resolveSrcToken} but rejects unknown, paused, and delisted + * tokens. Used on the create path. Reads of an already-stored intent do not + * come through here. */ - async resolveSrcTokenOrThrow( - chain: SupportedChain, - address: string, - ): Promise { + async resolveSrcTokenOrThrow(chain: SupportedChain, address: string): Promise { + const stored = this.repo.findByAddressAndChain(address, chain); + this.assertOfferable(stored, address, chain); const token = await this.resolveSrcToken(chain, address); if (!token) { throw new BadRequestException( @@ -119,31 +102,22 @@ export class TokensService { return token; } - /** - * Like {@link resolveDstToken} but throws a `BadRequestException` instead of - * returning `undefined` when the contract does not resolve to a known Stellar - * token (issue #276). - */ + /** Like {@link resolveDstToken} but rejects unknown, paused, and delisted tokens. */ async resolveDstTokenOrThrow(contract: string): Promise { + const stored = this.repo.findByAddressAndChain(contract, "stellar"); + this.assertOfferable(stored, contract, "stellar"); const token = await this.resolveDstToken(contract); if (!token) { - throw new BadRequestException( - "Unknown destination token contract for the configured token registry", - ); + throw new BadRequestException("Unknown destination token contract for the configured token registry"); } return token; } - private toApiToken(record: TokenRecord): ApiToken { /** * Normalise a stored {@link TokenRecord} into the public token shape. - * - * Both `address` and `contract` are emitted with the same value so clients - * can read either field regardless of whether the token is EVM- or - * Stellar-native — the registry stores every token under `address`, but the - * Stellar side of the API has always used `contract`. + * Delisted rows are omitted by {@link getByChain}; this helper does not filter. */ - private toApiToken(record: TokenRecord) { + private toApiToken(record: TokenRecord): ApiToken { return { address: record.address, contract: record.address, @@ -154,92 +128,42 @@ export class TokensService { }; } - getByChain(chain?: string): TokensByChainResponse { - const chainRecords = - chain !== undefined && (chain === "stellar" || chain in SUPPORTED_TOKENS) - ? this.repo.findByChain(chain) - : this.repo.findAll(); - const stellarTokens = chainRecords.filter((record) => record.chain === "stellar"); - - if (chain === "stellar") { - return { tokens: stellarTokens.map((record) => this.toApiToken(record)), chain: "stellar" }; - } - if (chain !== undefined && chain in SUPPORTED_TOKENS) { - return { - tokens: chainRecords - .filter((record) => record.chain === chain) - .map((record) => this.toApiToken(record)), - chain, /** * Return the supported token registry, optionally narrowed to one chain. - * - * - `chain="stellar"` → `{ tokens: StellarToken[], chain: "stellar" }` - * - `chain=` → `{ tokens: Token[], chain }` - * - omitted / unknown → `{ tokens: Record, stellarTokens: Token[] }` - * - * An unrecognised chain deliberately falls back to the full registry rather - * than erroring: this endpoint feeds discovery UIs, and a client with a - * stale chain list should see everything, not a 4xx. + * Delisted tokens are hidden. Paused tokens stay visible. */ - async getByChain(chain?: string) { + async getByChain(chain?: string): Promise { const requested = chain?.toLowerCase(); if (requested === "stellar") { - const records = await this.repo.findByChain("stellar"); - return { - tokens: records.map((record) => this.toApiToken(record)), - chain: "stellar", - }; + const records = this.listed(await Promise.resolve(this.repo.findByChain("stellar"))); + return { tokens: records.map((record) => this.toApiToken(record)), chain: "stellar" }; } if (requested && requested in SUPPORTED_TOKENS) { - const records = await this.repo.findByChain(requested); + const records = this.listed(await Promise.resolve(this.repo.findByChain(requested))); return { - tokens: records - .filter((record) => record.chain === requested) - .map((record) => this.toApiToken(record)), + tokens: records.filter((record) => record.chain === requested).map((record) => this.toApiToken(record)), chain: requested, }; } - const all = 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 - // than vanishing from the response shape. - const byChain: Record[]> = {}; - for (const key of Object.keys(SUPPORTED_TOKENS)) { - byChain[key] = []; - } + const all = this.listed(await Promise.resolve(this.repo.findAll())); + const byChain: Record = {}; + for (const key of Object.keys(SUPPORTED_TOKENS)) byChain[key] = []; for (const record of all) { if (!byChain[record.chain]) byChain[record.chain] = []; byChain[record.chain].push(this.toApiToken(record)); } return { - tokens: Object.fromEntries( - Object.entries(SUPPORTED_TOKENS).map(([key, _]) => [ - key, - chainRecords - .filter((record) => record.chain === key) - .map((record) => this.toApiToken(record)), - ]), - ), - stellarTokens: stellarTokens.map((record) => this.toApiToken(record)), - }; - } - - getStellarTokens(): { tokens: StellarToken[] } { - const records = this.repo.findByChain("stellar"); tokens: byChain, - stellarTokens: all - .filter((record) => record.chain === "stellar") - .map((record) => this.toApiToken(record)), + stellarTokens: all.filter((record) => record.chain === "stellar").map((record) => this.toApiToken(record)), }; } async getStellarTokens(): Promise<{ tokens: StellarToken[] }> { - const records = await this.repo.findByChain("stellar"); + const records = this.listed(await Promise.resolve(this.repo.findByChain("stellar"))); return { tokens: records.map((record) => ({ contract: record.address, @@ -250,4 +174,15 @@ export class TokensService { })), }; } + + private listed(records: TokenRecord[]): TokenRecord[] { + return records.filter((record) => (record.status ?? "active") !== "delisted"); + } + + private assertOfferable(record: TokenRecord | undefined, address: string, chain: string): void { + const status = record?.status ?? "active"; + if (record && status !== "active") { + throw new BadRequestException(`Token '${address}' on '${chain}' is ${status} and cannot be used for a new intent`); + } + } } 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; + } +} diff --git a/src/treasury/treasury.service.spec.ts b/src/treasury/treasury.service.spec.ts index b223f9f..8688ce0 100644 --- a/src/treasury/treasury.service.spec.ts +++ b/src/treasury/treasury.service.spec.ts @@ -6,7 +6,7 @@ import { SorobanService } from "../soroban/soroban.service"; describe("TreasuryService", () => { let service: TreasuryService; - let prisma: jest.Mocked; + let prisma: any; let soroban: jest.Mocked; let configService: jest.Mocked; @@ -52,7 +52,7 @@ describe("TreasuryService", () => { }).compile(); service = module.get(TreasuryService); - prisma = module.get(PrismaService) as jest.Mocked; + prisma = module.get(PrismaService) as any; soroban = module.get(SorobanService) as jest.Mocked; configService = module.get(ConfigService) as jest.Mocked; }); diff --git a/src/treasury/treasury.service.ts b/src/treasury/treasury.service.ts index a9ac6e9..e512df9 100644 --- a/src/treasury/treasury.service.ts +++ b/src/treasury/treasury.service.ts @@ -164,11 +164,10 @@ export class TreasuryService { // Handle issued assets (traditional Stellar assets) const [code, issuer] = asset.split(":"); if (issuer) { - const balance = account.balances.find( - (b) => b.asset_type !== "native" && - b.asset_code === code && - b.asset_issuer === issuer, - ); + const balance = account.balances.find((b) => { + if (b.asset_type === "native" || b.asset_type === "liquidity_pool_shares") return false; + return b.asset_code === code && b.asset_issuer === issuer; + }); return { asset, balance: balance ? this.parseBalance(balance.balance) : "0", diff --git a/test/__mocks__/nestjs-schedule.ts b/test/__mocks__/nestjs-schedule.ts new file mode 100644 index 0000000..d1758c3 --- /dev/null +++ b/test/__mocks__/nestjs-schedule.ts @@ -0,0 +1,24 @@ +/** Jest stand-in for the ESM-only @nestjs/schedule package. */ +export const CronExpression = { + EVERY_MINUTE: "* * * * *", + EVERY_5_MINUTES: "*/5 * * * *", + EVERY_DAY_AT_MIDNIGHT: "0 0 * * *", +}; + +export function Cron(): MethodDecorator { + return () => undefined; +} + +export function Interval(): MethodDecorator { + return () => undefined; +} + +export function Timeout(): MethodDecorator { + return () => undefined; +} + +export class ScheduleModule { + static forRoot(): { module: typeof ScheduleModule } { + return { module: ScheduleModule }; + } +} diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 910ad19..dbe9abb 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -7,6 +7,7 @@ "^.+\\.ts$": ["ts-jest", { "diagnostics": false }] }, "moduleNameMapper": { - "^@stellar/stellar-sdk$": "/test/__mocks__/@stellar/stellar-sdk.ts" + "^@stellar/stellar-sdk$": "/test/__mocks__/@stellar/stellar-sdk.ts", + "^@nestjs/schedule$": "/test/__mocks__/nestjs-schedule.ts" } }