From e7bea21511d437a3d07f7fcc82ee5a7fa5f092d2 Mon Sep 17 00:00:00 2001 From: meetdarc-tech Date: Sun, 30 Aug 2026 04:58:55 +0100 Subject: [PATCH] feat: implement Soroban intent settlement flow --- .env.example | 1 + src/config/configuration.ts | 2 + src/config/env.validation.ts | 1 + src/intents/intents.controller.ts | 4 +- src/intents/intents.module.ts | 7 +- src/intents/intents.service.ts | 47 ++++++++++- src/soroban/event-ingestion.service.ts | 85 ++++++++++++++++++++ src/soroban/signer.service.ts | 38 +++++++++ src/soroban/soroban.module.ts | 11 ++- src/soroban/soroban.service.ts | 17 ++++ src/soroban/stellar-tx.service.ts | 103 +++++++++++++++++++++++++ 11 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/soroban/event-ingestion.service.ts create mode 100644 src/soroban/signer.service.ts create mode 100644 src/soroban/stellar-tx.service.ts diff --git a/.env.example b/.env.example index fa8bba2..9765142 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ PORT=4000 STELLAR_NETWORK=testnet # Soroban RPC endpoint SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_SIGNER_SECRET_KEY= # Deployed contract IDs (leave blank until deployed) SETTLEMENT_CONTRACT_ID= diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b6c1100..bf42a1c 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -6,6 +6,7 @@ export interface AppConfig { sorobanRpcUrl: string; settlementContractId: string; solverRegistryContractId: string; + signerSecretKey: string; }; corsOrigin: string; } @@ -18,6 +19,7 @@ export default (): AppConfig => ({ sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "", solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", + signerSecretKey: process.env.STELLAR_SIGNER_SECRET_KEY ?? "", }, corsOrigin: process.env.CORS_ORIGIN ?? "*", }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 9a4dc6d..18a0238 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -8,6 +8,7 @@ export const envValidationSchema = Joi.object({ SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), + STELLAR_SIGNER_SECRET_KEY: Joi.string().allow("").default(""), CORS_ORIGIN: Joi.string().default("*"), }); diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 6d3296d..cce5e94 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -71,9 +71,9 @@ export class IntentsController { } @Post() - create(@Body() dto: CreateIntentDto) { + async create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); - const intent = this.intentsService.create({ + const intent = await this.intentsService.create({ user: dto.user, srcChain: dto.srcChain, srcToken: { diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 372ed2a..f80a4e4 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -1,14 +1,15 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { IntentsService } from "./intents.service"; import { IntentsController } from "./intents.controller"; import { IntentsGateway } from "./intents.gateway"; import { IntentsSweeperService } from "./intents-sweeper.service"; import { SolversModule } from "../solvers/solvers.module"; +import { SorobanModule } from "../soroban/soroban.module"; @Module({ - imports: [SolversModule], + imports: [SolversModule, forwardRef(() => SorobanModule)], controllers: [IntentsController], providers: [IntentsService, IntentsGateway, IntentsSweeperService], - exports: [IntentsService], + exports: [IntentsService, IntentsGateway], }) export class IntentsModule {} diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 86ed406..9948e14 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -1,5 +1,9 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Optional } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Address, xdr } from "@stellar/stellar-sdk"; import { v4 as uuidv4 } from "uuid"; +import { AppConfig } from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; import { Intent, IntentState } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; @@ -7,11 +11,14 @@ import { buildSeedIntents } from "./intents.seed"; export class IntentsService { private readonly intents = new Map(); - constructor() { + constructor( + @Optional() private readonly stellarTxService?: StellarTxService, + @Optional() private readonly configService?: ConfigService, + ) { this.seed(); } - create(data: Omit): Intent { + async create(data: Omit): Promise { const now = Math.floor(Date.now() / 1000); const intent: Intent = { ...data, @@ -20,10 +27,33 @@ export class IntentsService { createdAt: now, deadline: data.deadline ?? now + 1800, }; + const enabled = process.env.ONCHAIN_INTENTS_ENABLED === "true"; + if (enabled) { + const contractId = this.configService?.get("stellar.settlementContractId", { infer: true }); + if (!contractId || !this.stellarTxService) throw new Error("On-chain intent registration is not configured"); + await this.stellarTxService.invokeContract({ + contractId, + method: "create_intent", + args: this.buildCreateIntentArgs(intent), + }); + } this.intents.set(intent.intentId, intent); return intent; } + private buildCreateIntentArgs(intent: Intent): xdr.ScVal[] { + return [ + xdr.ScVal.scvString(intent.intentId), + xdr.ScVal.scvAddress(Address.fromString(intent.user).toScAddress()), + xdr.ScVal.scvString(intent.srcChain), + xdr.ScVal.scvString(intent.srcToken.address), + xdr.ScVal.scvU128(xdr.UInt128.fromString(intent.srcAmount)), + xdr.ScVal.scvAddress(Address.fromString(intent.dstToken.contract).toScAddress()), + xdr.ScVal.scvU128(xdr.UInt128.fromString(intent.minDstAmount)), + xdr.ScVal.scvU64(xdr.Uint64.fromString(String(intent.deadline))), + ]; + } + get(id: string): Intent | undefined { return this.intents.get(id); } @@ -48,6 +78,17 @@ export class IntentsService { return updated; } + reconcileFilled(id: string, fillAmount: string, txHash?: string): Intent | null { + const existing = this.intents.get(id); + if (!existing) return null; + return this.update(id, { + state: "filled", + filledAt: Math.floor(Date.now() / 1000), + fillAmount, + txHash, + }); + } + private seed() { const now = Math.floor(Date.now() / 1000); for (const data of buildSeedIntents(now)) { diff --git a/src/soroban/event-ingestion.service.ts b/src/soroban/event-ingestion.service.ts new file mode 100644 index 0000000..434d474 --- /dev/null +++ b/src/soroban/event-ingestion.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { promises as fs } from "fs"; +import { join } from "path"; +import { IntentsGateway } from "../intents/intents.gateway"; +import { IntentsService } from "../intents/intents.service"; +import { SorobanService } from "./soroban.service"; + +interface IntentFilledEvent { + ledger?: number; + transactionHash?: string; + txHash?: string; + topics?: unknown[]; + topic?: unknown[]; + data?: unknown; + [key: string]: unknown; +} + +@Injectable() +export class EventIngestionService implements OnModuleInit { + private readonly logger = new Logger(EventIngestionService.name); + private readonly cursorPath = join(process.cwd(), ".data", "soroban-event-cursor.json"); + private nextStartLedger?: number; + private polling = false; + + constructor( + private readonly sorobanService: SorobanService, + private readonly intentsService: IntentsService, + private readonly intentsGateway: IntentsGateway, + ) {} + + async onModuleInit() { + try { + const saved = JSON.parse(await fs.readFile(this.cursorPath, "utf8")) as { nextStartLedger?: number }; + if (Number.isInteger(saved.nextStartLedger)) this.nextStartLedger = saved.nextStartLedger; + } catch { + // A missing cursor intentionally starts at the latest ledger on first deployment. + } + } + + async poll() { + if (this.polling) return; + this.polling = true; + try { + const startLedger = this.nextStartLedger ?? (await this.sorobanService.getLatestLedger()).sequence; + const response: any = await this.sorobanService.getEvents(startLedger); + for (const event of response.events ?? []) { + if (event.topic?.[0]?.value === "intent_filled" || event.name === "intent_filled") { + await this.handleIntentFilled(event); + } + } + const latest = response.latestLedger ?? response.latestLedgerSequence ?? startLedger; + this.nextStartLedger = Math.max(startLedger, latest + 1); + await this.persistCursor(); + } finally { + this.polling = false; + } + } + + async handleIntentFilled(event: IntentFilledEvent) { + const topicValues = (event.topics ?? event.topic ?? []).map((value: any) => value?.value ?? value); + const intentIndex = topicValues[0] === "intent_filled" ? 1 : 0; + const intentId = String(topicValues[intentIndex] ?? ""); + const fillValue: any = event.data ?? topicValues[intentIndex + 1] ?? "0"; + const fillAmount = String(fillValue?.value ?? fillValue); + const txHash = event.transactionHash ?? event.txHash; + if (!intentId) return; + + const updated = this.intentsService.reconcileFilled(intentId, fillAmount, txHash); + if (!updated) { + this.logger.warn(`Ignoring intent_filled for unknown local intent ${intentId}`); + return; + } + this.intentsGateway.broadcast({ + type: "intent_filled", + intentId, + fillAmount, + txHash, + }); + } + + private async persistCursor() { + await fs.mkdir(join(process.cwd(), ".data"), { recursive: true }); + await fs.writeFile(this.cursorPath, JSON.stringify({ nextStartLedger: this.nextStartLedger }), "utf8"); + } +} diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts new file mode 100644 index 0000000..5f0db16 --- /dev/null +++ b/src/soroban/signer.service.ts @@ -0,0 +1,38 @@ +import { Injectable, ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Account, Keypair, Transaction } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { SorobanService } from "./soroban.service"; + +@Injectable() +export class SignerService { + private keypair?: Keypair; + + constructor( + private readonly configService: ConfigService, + private readonly sorobanService: SorobanService, + ) { + } + + get publicKey(): string { + return this.getKeypair().publicKey(); + } + + async withNextSequence(callback: (account: Account) => Promise): Promise { + const account = await this.sorobanService.getAccount(this.publicKey); + return callback(account); + } + + sign(transaction: Transaction): Transaction { + transaction.sign(this.getKeypair()); + return transaction; + } + + private getKeypair(): Keypair { + if (this.keypair) return this.keypair; + const secret = this.configService.get("stellar.signerSecretKey", { infer: true }); + if (!secret) throw new ServiceUnavailableException("STELLAR_SIGNER_SECRET_KEY is not configured"); + this.keypair = Keypair.fromSecret(secret); + return this.keypair; + } +} diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 67d377d..8b5d53b 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,10 +1,15 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; +import { StellarTxService } from "./stellar-tx.service"; +import { EventIngestionService } from "./event-ingestion.service"; +import { IntentsModule } from "../intents/intents.module"; @Module({ + imports: [forwardRef(() => IntentsModule)], controllers: [SorobanController], - providers: [SorobanService], - exports: [SorobanService], + providers: [SorobanService, SignerService, StellarTxService, EventIngestionService], + exports: [SorobanService, SignerService, StellarTxService, EventIngestionService], }) export class SorobanModule {} diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index 0b32651..ed0e88a 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SorobanRpc } from "@stellar/stellar-sdk"; +import { Transaction } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; @Injectable() @@ -27,4 +28,20 @@ export class SorobanService { getAccount(publicKey: string) { return this.server.getAccount(publicKey); } + + simulateTransaction(transaction: Transaction) { + return this.server.simulateTransaction(transaction); + } + + sendTransaction(transaction: Transaction) { + return this.server.sendTransaction(transaction); + } + + getTransaction(hash: string) { + return this.server.getTransaction(hash); + } + + getEvents(startLedger: number) { + return this.server.getEvents({ startLedger }); + } } diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts new file mode 100644 index 0000000..ec38b90 --- /dev/null +++ b/src/soroban/stellar-tx.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { + BASE_FEE, + Contract, + Networks, + Operation, + SorobanRpc, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { SignerService } from "./signer.service"; +import { SorobanService } from "./soroban.service"; + +export interface InvokeContractParams { + contractId: string; + method: string; + args: xdr.ScVal[]; +} + +export interface InvokeContractResult { + hash: string; + status: string; +} + +export class SorobanSimulationError extends Error {} +export class SorobanSubmissionError extends Error {} +export class SorobanPollingTimeoutError extends Error {} + +@Injectable() +export class StellarTxService { + private readonly networkPassphrase: string; + private readonly maxPolls = 30; + private readonly pollIntervalMs = 1000; + + constructor( + private readonly configService: ConfigService, + private readonly sorobanService: SorobanService, + private readonly signerService: SignerService, + ) { + const network = configService.get("stellar.network", { infer: true }); + this.networkPassphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + } + + async invokeContract(params: InvokeContractParams): Promise { + return this.signerService.withNextSequence(async (account) => { + const transaction = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: this.networkPassphrase }) + .addOperation( + Operation.invokeHostFunction({ + func: new Contract(params.contractId).call(params.method, ...params.args), + auth: [], + }), + ) + .setTimeout(300) + .build(); + + const prepared = await this.prepareTransaction(transaction); + const signed = this.signerService.sign(prepared); + let submitted: any; + try { + submitted = await this.sorobanService.sendTransaction(signed); + } catch (error) { + throw new SorobanSubmissionError(`Soroban transaction submission failed: ${String(error)}`); + } + + if (submitted.status === "ERROR") { + throw new SorobanSubmissionError(submitted.errorResult ?? "Soroban transaction was rejected"); + } + return this.waitForConfirmation(submitted.hash); + }); + } + + private async prepareTransaction(transaction: Transaction): Promise { + let simulation: any; + try { + simulation = await this.sorobanService.simulateTransaction(transaction); + } catch (error) { + throw new SorobanSimulationError(`Soroban simulation failed: ${String(error)}`); + } + if (!SorobanRpc.Api.isSimulationSuccess(simulation)) { + throw new SorobanSimulationError(simulation.error ?? "Soroban simulation was rejected"); + } + try { + return SorobanRpc.assembleTransaction(transaction, simulation).build(); + } catch (error) { + throw new SorobanSimulationError(`Soroban transaction preparation failed: ${String(error)}`); + } + } + + private async waitForConfirmation(hash: string): Promise { + for (let poll = 0; poll < this.maxPolls; poll += 1) { + const result: any = await this.sorobanService.getTransaction(hash); + if (result.status === "SUCCESS") return { hash, status: result.status }; + if (result.status === "FAILED") { + throw new SorobanSubmissionError(result.errorResult ?? "Soroban transaction failed"); + } + await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs)); + } + throw new SorobanPollingTimeoutError(`Timed out waiting for Soroban transaction ${hash}`); + } +}