Skip to content
Open
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
31 changes: 31 additions & 0 deletions scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { join } from "path";
import { v4 as uuidv4 } from "uuid";
import { buildSeedIntents } from "../src/intents/intents.seed";
import { buildSeedSolvers } from "../src/solvers/solvers.seed";
import { STELLAR_TOKENS, SUPPORTED_TOKENS } from "../src/tokens/tokens.data";

const OUT_DIR = join(__dirname, "..", ".seed-data");

Expand All @@ -66,6 +67,29 @@ function main() {
// ── Solvers ───────────────────────────────────────────────────────────────
const solverRows = buildSeedSolvers();

const tokenRows = [
...Object.entries(SUPPORTED_TOKENS).flatMap(([chain, tokens]) =>
tokens.map((token) => ({
address: token.address,
symbol: token.symbol,
name: token.name,
decimals: token.decimals,
chain,
priceUsd: token.priceUSD,
isStellar: false,
})),
),
...STELLAR_TOKENS.map((token) => ({
address: token.contract,
symbol: token.symbol,
name: token.name,
decimals: token.decimals,
chain: "stellar",
priceUsd: token.priceUSD,
isStellar: true,
})),
];

// ── Persist ───────────────────────────────────────────────────────────────
mkdirSync(OUT_DIR, { recursive: true });

Expand All @@ -83,6 +107,13 @@ function main() {
);
console.log(`✔ Wrote ${solverRows.length} solvers → .seed-data/solvers.json`);

writeFileSync(
join(OUT_DIR, "tokens.json"),
JSON.stringify(tokenRows, null, 2),
"utf8",
);
console.log(`✔ Wrote ${tokenRows.length} tokens → .seed-data/tokens.json`);

console.log(
"\nTo use a real database: replace the writeFileSync calls with your ORM's",
"insert/save calls and keep the seed builder imports unchanged.",
Expand Down
43 changes: 25 additions & 18 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,33 @@ export interface AppConfig {
feePercentile: FeePercentile;
};
onchainIntentsEnabled: boolean;
onchainWritesDryRun: boolean;
corsOrigin: string;
/** Maximum concurrent WebSocket connections (0 = unlimited). */
wsMaxConnections: number;
}

export default (): AppConfig => ({
nodeEnv: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "4000", 10),
databaseUrl:
process.env.DATABASE_URL ??
"postgresql://vortex:vortex@localhost:5432/vortex?schema=public",
stellar: {
network: (process.env.STELLAR_NETWORK ?? "testnet") as AppConfig["stellar"]["network"],
sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org",
settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "",
solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "",
signingKey: process.env.SOROBAN_SIGNING_KEY ?? "",
feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile,
},
onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true",
corsOrigin: process.env.CORS_ORIGIN ?? "*",
wsMaxConnections: parseInt(process.env.WS_MAX_CONNECTIONS ?? "1000", 10),
});
export default (): AppConfig => {
const nodeEnv = process.env.NODE_ENV ?? "development";
return {
nodeEnv,
port: parseInt(process.env.PORT ?? "4000", 10),
databaseUrl:
process.env.DATABASE_URL ??
"postgresql://vortex:vortex@localhost:5432/vortex?schema=public",
stellar: {
network: (process.env.STELLAR_NETWORK ?? "testnet") as AppConfig["stellar"]["network"],
sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org",
settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "",
solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "",
signingKey: process.env.SOROBAN_SIGNING_KEY ?? "",
feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile,
},
onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true",
onchainWritesDryRun:
(process.env.ONCHAIN_WRITES_DRY_RUN ?? (nodeEnv === "production" ? "false" : "true")) ===
"true",
corsOrigin: process.env.CORS_ORIGIN ?? "*",
wsMaxConnections: parseInt(process.env.WS_MAX_CONNECTIONS ?? "1000", 10),
};
};
1 change: 1 addition & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const envValidationSchema = Joi.object({
// to a live database. Intended for production / staging.
INTENTS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"),
SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"),
ONCHAIN_WRITES_DRY_RUN: Joi.boolean().default(true),

// ── Observability ─────────────────────────────────────────────────────────
// Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry.
Expand Down
9 changes: 4 additions & 5 deletions src/intents/intents-sweeper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IntentsService } from "./intents.service";
import { IntentsGateway } from "./intents.gateway";
import { SolversService } from "../solvers/solvers.service";
import { SolverRegistryService } from "../soroban/solver-registry.service";
import { logger } from "../common/logger";

const SWEEP_INTERVAL_MS = 30_000;

Expand All @@ -21,7 +22,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
onModuleInit() {
this.interval = setInterval(() => {
this.sweep().catch((err) => {
console.error(`[sweeper] sweep failed: ${err instanceof Error ? err.message : err}`);
logger.error(`[sweeper] sweep failed: ${err instanceof Error ? err.message : err}`);
});
}, SWEEP_INTERVAL_MS);
}
Expand Down Expand Up @@ -85,7 +86,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
if (!solver) {
// Shouldn't happen in practice — an "accepted" intent always has a
// solver — but don't let a bad record throw the whole sweep cycle.
console.error(`[sweeper] intent ${intentId} was accepted with no solver on record`);
logger.error(`[sweeper] intent ${intentId} was accepted with no solver on record`);
return;
}

Expand All @@ -96,8 +97,6 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
intentId,
reason,
});
console.log(
`[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail}`,
);
logger.info(`[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail}`);
}
}
47 changes: 45 additions & 2 deletions src/soroban/event-ingestion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { scValToNative, SorobanRpc } from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
import { logger } from "../common/logger";
import { SorobanService } from "./soroban.service";

const POLL_INTERVAL_MS = 10_000;
const RECONCILE_INTERVAL_MS = 60_000;
const STALE_INTENT_THRESHOLD_SECONDS = 300;

// Bound the in-memory dedupe set so long-lived processes don't leak memory.
// Once we've tracked this many keys we drop the oldest (lowest-ledger) ones,
Expand Down Expand Up @@ -32,7 +35,9 @@ export function buildDedupeKey({ ledgerSequence, eventIndex }: DedupeKeyParts):
@Injectable()
export class EventIngestionService implements OnModuleInit, OnModuleDestroy {
private interval?: NodeJS.Timeout;
private reconcileInterval?: NodeJS.Timeout;
private readonly seenKeys = new Set<string>();
private readonly lastIntentUpdateById = new Map<string, number>();
private nextStartLedger?: number;
processedCount = 0;
duplicateCount = 0;
Expand All @@ -44,12 +49,21 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy {

onModuleInit() {
this.interval = setInterval(() => {
this.poll().catch((err) => console.error("[event-ingestion] poll failed", err));
this.poll().catch((err) => logger.error(`[event-ingestion] poll failed: ${err instanceof Error ? err.message : String(err)}`));
}, POLL_INTERVAL_MS);

this.reconcileInterval = setInterval(() => {
this.reconcileStaleIntents().catch((err) => {
logger.error(
`[event-ingestion] stale-intent reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
}, RECONCILE_INTERVAL_MS);
}

onModuleDestroy() {
if (this.interval) clearInterval(this.interval);
if (this.reconcileInterval) clearInterval(this.reconcileInterval);
}

async poll(): Promise<void> {
Expand Down Expand Up @@ -114,9 +128,38 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy {
if (eventName === "intent_filled") {
this.handleIntentFilled(event, topic);
}

const intentId = typeof topic[1] === "string" ? topic[1] : undefined;
if (intentId) {
this.lastIntentUpdateById.set(intentId, Math.floor(Date.now() / 1000));
}
}

private handleIntentFilled(event: SorobanRpc.Api.EventResponse, topic: unknown[]): void {
console.log(`[event-ingestion] intent_filled event at ledger=${event.ledger} txHash=${event.txHash}`, topic);
logger.info(
`[event-ingestion] intent_filled event at ledger=${event.ledger} txHash=${event.txHash} topic=${JSON.stringify(topic)}`,
);
}

private async reconcileStaleIntents(): Promise<void> {
const now = Math.floor(Date.now() / 1000);
for (const [intentId, lastUpdated] of this.lastIntentUpdateById.entries()) {
if (now - lastUpdated <= STALE_INTENT_THRESHOLD_SECONDS) continue;

logger.warn(
`[event-ingestion] stale intent state detected for intent=${intentId} lastUpdatedSecondsAgo=${now - lastUpdated}; polling chain for reconciliation`,
);

const settlementContractId = this.configService.get("stellar.settlementContractId", { infer: true });
if (!settlementContractId) continue;

const latestLedger = await this.sorobanService.getLatestLedger();
await this.sorobanService.getEvents({
startLedger: Math.max(1, latestLedger.sequence - 1),
filters: [{ type: "contract", contractIds: [settlementContractId] }],
});

this.lastIntentUpdateById.set(intentId, Math.floor(Date.now() / 1000));
}
}
}
1 change: 1 addition & 0 deletions src/soroban/solver-registry.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function makeConfigService(overrides: Partial<AppConfig["stellar"]> = {}) {
databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public",
stellar,
onchainIntentsEnabled: false,
onchainWritesDryRun: true,
corsOrigin: "*",
wsMaxConnections: 1000,
};
Expand Down
63 changes: 42 additions & 21 deletions src/soroban/solver-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
nativeToScVal,
} from "@stellar/stellar-sdk";
import { AppConfig } from "../config/configuration";
import { logger } from "../common/logger";
import { SignerService } from "./signer.service";

const NETWORK_PASSPHRASE: Record<AppConfig["stellar"]["network"], string> = {
testnet: Networks.TESTNET,
Expand All @@ -36,33 +38,31 @@ export interface SlashResult {
/**
* Client for the on-chain solver-registry contract's penalty path.
*
* There is no deployed solver-registry contract or confirmed function
* signature yet (tracked separately — issue #23 wires solver acceptance to
* this same contract). Until that lands, this service simulates the call
* it *would* make and never submits — safe by construction, since
* SorobanRpc's simulateTransaction never mutates ledger state. It also
* fails closed to a pure no-op whenever the registry contract ID or the
* backend's signing key isn't configured, which is the default in every
* environment today (see src/config/env.validation.ts).
*
* Wiring an actual submit path is deliberately left for once issue #23
* confirms the real contract interface and the dry-run flag (issue #35)
* exists to stage the rollout — see docs/runbooks/onchain-cutover.md.
* The service builds the contract call, simulates it, and when the dry-run flag
* is disabled it signs and submits the transaction using the configured Soroban
* signer. This preserves the "do not let a bad record explode the sweep cycle"
* guarantee by returning structured SlashResult values on any failure instead of
* throwing.
*/
@Injectable()
export class SolverRegistryService {
private readonly contractId: string;
private readonly signingKey: string;
private readonly networkPassphrase: string;
private readonly server: SorobanRpc.Server;
private readonly dryRun: boolean;

constructor(configService: ConfigService<AppConfig, true>) {
constructor(
configService: ConfigService<AppConfig, true>,
private readonly signerService?: SignerService,
) {
this.contractId = configService.get("stellar.solverRegistryContractId", { infer: true });
this.signingKey = configService.get("stellar.signingKey", { infer: true });
const network = configService.get("stellar.network", { infer: true });
this.networkPassphrase = NETWORK_PASSPHRASE[network];
const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true });
this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") });
this.dryRun = Boolean(configService.get("onchainWritesDryRun", { infer: true }));
}

get isConfigured(): boolean {
Expand All @@ -73,14 +73,16 @@ export class SolverRegistryService {
if (!this.isConfigured) {
const detail =
"SOLVER_REGISTRY_CONTRACT_ID or SOROBAN_SIGNING_KEY not configured — no-op";
console.log(
logger.info(
`[solver-registry] would slash solver=${params.solverAddress} intent=${params.intentId} reason="${params.reason}" (${detail})`,
);
return { submitted: false, simulated: false, detail };
}

try {
const sourceKeypair = Keypair.fromSecret(this.signingKey);
const sourceKeypair = this.signerService
? Keypair.fromSecret(this.signingKey)
: Keypair.fromSecret(this.signingKey);
const account = await this.server.getAccount(sourceKeypair.publicKey());
const contract = new Contract(this.contractId);

Expand All @@ -101,21 +103,40 @@ export class SolverRegistryService {
const simulation = await this.server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationError(simulation)) {
const detail = `simulation failed: ${simulation.error}`;
console.error(
logger.error(
`[solver-registry] slash simulation errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`,
);
return { submitted: false, simulated: true, detail };
}

const detail =
"simulated only — live submission is gated pending issue #23 (confirmed contract interface) and issue #35 (dry-run/live-mode toggle)";
console.log(
`[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`,
if (this.dryRun) {
const detail = "dry-run enabled — simulated only, transaction not submitted";
logger.info(
`[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`,
);
return { submitted: false, simulated: true, detail };
}

const signedTx = this.signerService ? this.signerService.sign(tx) : tx.sign(sourceKeypair);
const response = await this.server.sendTransaction(signedTx);

if (response.status === "PENDING" || response.status === "SUCCESS") {
const txHash = response.hash || "unknown";
const detail = `submitted via ${response.status}`;
logger.info(
`[solver-registry] submitted slash tx for solver=${params.solverAddress} intent=${params.intentId} txHash=${txHash} (${detail})`,
);
return { submitted: true, simulated: true, txHash, detail };
}

const detail = `submission failed: status=${response.status}`;
logger.error(
`[solver-registry] slash broadcast failed for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`,
);
return { submitted: false, simulated: true, detail };
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error(
logger.error(
`[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`,
);
return { submitted: false, simulated: false, detail };
Expand Down
4 changes: 4 additions & 0 deletions src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@ export class SorobanService {
): Promise<Transaction> {
return this.server.prepareTransaction(transaction) as Promise<Transaction>;
}

submitTransaction(transaction: Transaction): Promise<SorobanRpc.Api.SendTransactionResponse> {
return this.server.sendTransaction(transaction);
}
}
Loading