Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions babel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"plugins": [
"@babel/plugin-transform-modules-commonjs"
]
}
37 changes: 21 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
}
43 changes: 30 additions & 13 deletions src/claim/claim.scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,43 @@ export class ClaimScheduler implements OnModuleInit, OnApplicationShutdown {
this.logger.log(`Claim scheduler initialized with interval ${interval}s`);
}

async onApplicationShutdown(): Promise<void> {
async onApplicationShutdown(signal?: string): Promise<void> {
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<boolean> {
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<void>((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<boolean>((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);
});
});
}

/**
Expand Down
158 changes: 136 additions & 22 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,7 +34,7 @@ export interface AppConfig {
poolContractId: string;
policyContractId: string;
oracleContractId: string;
relayerSecret?: string;
relayerSecret: string;
relayerSecretId?: string;
relayerSecretRegion?: string;
validateOnChain?: boolean;
Expand All @@ -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<FeeProfileName, { percentile: number; multiplier: number }>;
};
/** 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<string, { sorobanRpcUrl: string; horizonUrl: string; networkPassphrase: string }> = {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
};
};
13 changes: 10 additions & 3 deletions src/policy/policy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down