diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 0000000..0f94361 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,5 @@ +{ + "plugins": [ + "@babel/plugin-transform-modules-commonjs" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 3491dfb..9613b5e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "npm run build --workspaces --if-present", "dev": "nodemon --exec ts-node src/main.ts", "start": "node dist/main.js", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run test:unit && npm run test:contracts", "test:unit": "npm run test --workspaces --if-present", "test:contracts": "node test/contracts/verify.js", @@ -26,21 +26,6 @@ "engines": { "node": ">=18" }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "moduleFileExtensions": ["js", "json", "ts"], - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "transformIgnorePatterns": [ - "/node_modules/(?!(?:@stellar/stellar-sdk|@noble/.*|@scure/.*|uint8array-extras|hexoid|bignumber.js))" - ], - "collectCoverageFrom": ["**/*.(t|j)s"], - "coverageDirectory": "../coverage" - }, "dependencies": { "@aws-sdk/client-secrets-manager": "^3.1142.0", "@nestjs/common": "^11.1.28", @@ -95,5 +80,25 @@ "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^5.3.2" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "testMatch": [ + "**/src/**/*.spec.ts" + ], + "moduleFileExtensions": ["js", "json", "ts"], + "transform": { + "^.+\\.(t|j)s$": "ts-jest", + "^.+\\.ts$": "ts-jest", + "^.+\\.js$": "babel-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@stellar|@noble|@scure|stellar-base|bignumber.js|crc|tweetnacl|base32.js|uint8array-extras|@adraffy|@noble/curves)/)" + ], + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage" } } diff --git a/src/claim/claim.scheduler.ts b/src/claim/claim.scheduler.ts index fb9b15d..1252d35 100644 --- a/src/claim/claim.scheduler.ts +++ b/src/claim/claim.scheduler.ts @@ -50,26 +50,43 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown { this.logger.log(`Claim scheduler initialized with interval ${interval}s`); } - async onApplicationShutdown(): Promise { + async onApplicationShutdown(signal?: string): Promise { this.shuttingDown = true; - if (!this.inFlight) { - return; + + this.logger.log( + `ClaimScheduler shutdown (${signal ?? 'unknown'}): waiting up to ${SHUTDOWN_DRAIN_TIMEOUT_MS}ms for in-flight settlement run to finish.`, + ); + + const drained = await this.waitForInFlight(); + + if (drained) { + this.logger.log('In-flight settlement run completed cleanly.'); + } else { + this.logger.warn( + `In-flight settlement run did not finish within ${SHUTDOWN_DRAIN_TIMEOUT_MS}ms; proceeding with shutdown. The run may be interrupted.`, + ); + } + } + + private waitForInFlight(): Promise { + const run = this.inFlight; + if (!run) { + return Promise.resolve(true); } - this.logger.log('Waiting for in-flight claim settlement run to drain...'); - const timeout = new Promise((resolve) => { - const timer = setTimeout(() => { - this.logger.warn( - `In-flight claim settlement run did not finish within ${SHUTDOWN_DRAIN_TIMEOUT_MS}ms; proceeding with shutdown.`, - ); - resolve(); - }, SHUTDOWN_DRAIN_TIMEOUT_MS); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), SHUTDOWN_DRAIN_TIMEOUT_MS); if (typeof timer.unref === 'function') { timer.unref(); } - }); - await Promise.race([this.inFlight, timeout]); + run + .catch(() => undefined) + .then(() => { + clearTimeout(timer); + resolve(true); + }); + }); } /** diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 31583af..2964fa8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,6 +1,15 @@ /** * Typed application configuration, loaded once by Nest's ConfigModule. + * + * Kept as a single factory function (rather than scattered `process.env` + * reads) so every consumer gets the same parsed/defaulted values and the + * shape is documented in one place. See `.env.example` for the full list + * of variables this reads. */ + +/** Fee-estimation profiles exposed by FeeStrategyService. */ +export type FeeProfileName = "moderate" | "aggressive"; + export interface AppConfig { port: number; frontendUrl: string; @@ -25,7 +34,7 @@ export interface AppConfig { poolContractId: string; policyContractId: string; oracleContractId: string; - relayerSecret?: string; + relayerSecret: string; relayerSecretId?: string; relayerSecretRegion?: string; validateOnChain?: boolean; @@ -45,26 +54,79 @@ export interface AppConfig { pageLimit: number; }; }; - oracles: { - coingeckoBaseUrl: string; - horizonUrl: string; - defiLlamaBaseUrl: string; - defiLlamaProtocolSlug: string; - httpTimeoutMs: number; - }; - confirmation?: { + /** + * Shared Soroban confirmation poller defaults (see + * `pollForConfirmation`). Per-call overrides let the HTTP submit path + * use a short deadline while settlement/oracle publish use a longer one. + */ + confirmation: { initialIntervalMs: number; backoffMultiplier: number; maxIntervalMs: number; deadlineMs: number; jitterRatio: number; + /** Longer deadline for relayer-signed settlement / oracle publishes. */ settlementDeadlineMs: number; + /** Shorter deadline so HTTP handlers don't block for a full minute. */ httpDeadlineMs: number; }; - oraclePublish?: { + oraclePublish: { + /** + * `every_poll` — publish on every scheduler tick (subject to min interval). + * `on_change` — publish only when the scaled fixed-point value changes + * (default; bounds fee spend). + */ mode: "every_poll" | "on_change"; + /** Floor between publishes for the same coverage type (ms). */ minIntervalMs: number; }; + oracles: { + coingeckoBaseUrl: string; + horizonUrl: string; + defiLlamaBaseUrl: string; + defiLlamaProtocolSlug: string; + httpTimeoutMs: number; + }; + /** Soroban inclusion-fee estimation strategy. */ + fees: { + /** Hard ceiling (stroops) above which FeeCeilingExceededError is thrown. */ + ceilingStroops: string; + /** TTL for the cached getFeeStats response (ms). */ + statsTtlMs: number; + /** Per-profile percentile + multiplier used to derive an inclusion fee. */ + profiles: Record; + }; + /** Background Soroban event indexer settings. */ + eventIndexer: { + enabled: boolean; + pollIntervalMs: number; + lagAlertLedgers: number; + pageLimit: number; + }; + /** Ops-facing auth (bearer token + read-only community keys). */ + ops: { + apiToken: string; + communityApiKeys: string[]; + }; + /** Admin-only API key (x-api-key header). */ + adminApiKey: string; + /** Community-scoped API key records accepted on read-only endpoints. */ + communityApiKeys: Array<{ + key: string; + scope: "community"; + contact: string; + rateLimitPerMinute: number; + createdAt: string; + }>; + /** Named throttling tiers consumed by ThrottlerModule. */ + throttle: { + upstream: { ttl: number; limit: number }; + chain: { ttl: number; limit: number }; + default: { ttl: number; limit: number }; + catalog: { ttl: number; limit: number }; + }; + /** Pending off-chain policies that never get submitted expire after this many ms. */ + policyPendingTtlMs: number; } const NETWORK_CONFIG: Record = { @@ -106,7 +168,7 @@ export default (): AppConfig => { port: parseInt(process.env.PORT || "4001", 10), frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", database: { - url: process.env.DATABASE_URL || "postgresql://localhost:5432/refract", + url: process.env.DATABASE_URL || "postgres://refract:refract@localhost:5432/refract", poolMax: parseInt(process.env.DB_POOL_MAX || "10", 10), poolMin: parseInt(process.env.DB_POOL_MIN || "2", 10), poolIdleTimeoutMs: parseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS || "30000", 10), @@ -146,25 +208,77 @@ export default (): AppConfig => { pageLimit: parseInt(process.env.EVENT_INDEXER_PAGE_LIMIT || "100", 10), }, }, + confirmation: { + initialIntervalMs: parseInt(process.env.CONFIRMATION_INITIAL_INTERVAL_MS || "400", 10), + backoffMultiplier: parseFloat(process.env.CONFIRMATION_BACKOFF_MULTIPLIER || "1.8"), + maxIntervalMs: parseInt(process.env.CONFIRMATION_MAX_INTERVAL_MS || "8000", 10), + deadlineMs: parseInt(process.env.CONFIRMATION_DEADLINE_MS || "30000", 10), + jitterRatio: parseFloat(process.env.CONFIRMATION_JITTER_RATIO || "0.2"), + settlementDeadlineMs: parseInt(process.env.CONFIRMATION_SETTLEMENT_DEADLINE_MS || "90000", 10), + httpDeadlineMs: parseInt(process.env.CONFIRMATION_HTTP_DEADLINE_MS || "20000", 10), + }, + oraclePublish: { + mode: (process.env.ORACLE_PUBLISH_MODE as "every_poll" | "on_change") || "on_change", + minIntervalMs: parseInt(process.env.ORACLE_PUBLISH_MIN_INTERVAL_MS || "300000", 10), + }, oracles: { coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || "https://api.coingecko.com/api/v3", horizonUrl: selected.horizonUrl, defiLlamaBaseUrl: process.env.DEFILLAMA_BASE_URL || "https://api.llama.fi", + // Placeholder "covered protocol" for the SmartContractRisk TVL-drop + // check until Refract defines a real list of covered Soroban + // protocols. Defaults to a large, consistently-tracked protocol so + // the drop-detection logic has real data to run against. defiLlamaProtocolSlug: process.env.DEFILLAMA_PROTOCOL_SLUG || "aave", httpTimeoutMs: parseInt(process.env.ORACLE_HTTP_TIMEOUT_MS || "5000", 10), }, - confirmation: { - initialIntervalMs: parseInt(process.env.CONFIRMATION_INITIAL_INTERVAL_MS || "10", 10), - backoffMultiplier: parseFloat(process.env.CONFIRMATION_BACKOFF_MULTIPLIER || "1.5"), - maxIntervalMs: parseInt(process.env.CONFIRMATION_MAX_INTERVAL_MS || "50", 10), - deadlineMs: parseInt(process.env.CONFIRMATION_DEADLINE_MS || "200", 10), - jitterRatio: parseFloat(process.env.CONFIRMATION_JITTER_RATIO || "0"), - settlementDeadlineMs: parseInt(process.env.CONFIRMATION_SETTLEMENT_DEADLINE_MS || "200", 10), - httpDeadlineMs: parseInt(process.env.CONFIRMATION_HTTP_DEADLINE_MS || "100", 10), + fees: { + ceilingStroops: process.env.FEE_CEILING_STROOPS || "1000000", + statsTtlMs: parseInt(process.env.FEE_STATS_TTL_MS || "15000", 10), + profiles: { + moderate: { + percentile: parseInt(process.env.FEE_MODERATE_PERCENTILE || "90", 10), + multiplier: parseFloat(process.env.FEE_MODERATE_MULTIPLIER || "1.2"), + }, + aggressive: { + percentile: parseInt(process.env.FEE_AGGRESSIVE_PERCENTILE || "99", 10), + multiplier: parseFloat(process.env.FEE_AGGRESSIVE_MULTIPLIER || "1.5"), + }, + }, }, - oraclePublish: { - mode: (process.env.ORACLE_PUBLISH_MODE || "every_poll") as "every_poll" | "on_change", - minIntervalMs: parseInt(process.env.ORACLE_PUBLISH_MIN_INTERVAL_MS || "0", 10), + eventIndexer: { + enabled: process.env.EVENT_INDEXER_ENABLED !== "false", + pollIntervalMs: parseInt(process.env.EVENT_INDEXER_POLL_INTERVAL_MS || "5000", 10), + lagAlertLedgers: parseInt(process.env.EVENT_INDEXER_LAG_ALERT_LEDGERS || "7200", 10), + pageLimit: parseInt(process.env.EVENT_INDEXER_PAGE_LIMIT || "200", 10), + }, + ops: { + apiToken: process.env.OPS_API_TOKEN || "", + communityApiKeys: (process.env.COMMUNITY_API_KEYS || "") + .split(",") + .map((k) => k.trim()) + .filter(Boolean), + }, + adminApiKey: process.env.ADMIN_API_KEY || "", + communityApiKeys: [], + throttle: { + upstream: { + ttl: parseInt(process.env.THROTTLE_UPSTREAM_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_UPSTREAM_LIMIT || "60", 10), + }, + chain: { + ttl: parseInt(process.env.THROTTLE_CHAIN_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_CHAIN_LIMIT || "20", 10), + }, + default: { + ttl: parseInt(process.env.THROTTLE_DEFAULT_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_DEFAULT_LIMIT || "600", 10), + }, + catalog: { + ttl: parseInt(process.env.THROTTLE_CATALOG_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_CATALOG_LIMIT || "300", 10), + }, }, + policyPendingTtlMs: parseInt(process.env.POLICY_PENDING_TTL_MS || "3600000", 10), }; }; diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index 6ec7f48..d823540 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -206,13 +206,20 @@ export class PolicyService { } async onChainCoverageBounds(): Promise<{ minCoverage: bigint; maxCoverage: bigint } | null> { - if (!this.poolContractId) return null; + if (!this.poolContractId) { + return null; + } try { - const tx = new TransactionBuilder(new Account(Keypair.random().publicKey(), "0"), { + // pool_config() is a stateless view with no caller-specific args, so + // the source account only needs to be well-formed for the tx + // envelope — it never touches the network, unlike getAccount(). + const dummySource = new Account(Keypair.random().publicKey(), "0"); + const contract = new Contract(this.poolContractId); + const tx = new TransactionBuilder(dummySource, { fee: BASE_FEE, networkPassphrase: this.networkPassphrase, }) - .addOperation(new Contract(this.poolContractId).call("pool_config")) + .addOperation(contract.call("pool_config")) .setTimeout(30) .build();