From a36001be0720d9a005d169d02f8a43832952d0d0 Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 26 Jul 2026 08:06:38 +0000 Subject: [PATCH 1/4] feat: add Soroban transaction signer service Backend hot-wallet key is env-injected via SOROBAN_SIGNER_SECRET_KEY, never logged (custom toString/toJSON/inspect redact it), and required by env.validation.ts when NODE_ENV=production so boot fails fast instead of at first submit. Optional elsewhere so dev/test don't need a real key. Closes #21 --- .env.example | 4 ++ src/config/configuration.ts | 2 + src/config/env.validation.ts | 11 +++++ src/soroban/signer.service.spec.ts | 64 ++++++++++++++++++++++++++ src/soroban/signer.service.ts | 74 ++++++++++++++++++++++++++++++ src/soroban/soroban.module.ts | 5 +- 6 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/soroban/signer.service.spec.ts create mode 100644 src/soroban/signer.service.ts diff --git a/.env.example b/.env.example index fa8bba2..1b2f19c 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org SETTLEMENT_CONTRACT_ID= SOLVER_REGISTRY_CONTRACT_ID= +# Backend hot-wallet secret (StrKey "S...", 56 chars) used to sign settlement +# transactions. Required when NODE_ENV=production. Never commit a real value. +SOROBAN_SIGNER_SECRET_KEY= + # ─── CORS ──────────────────────────────────────────────────────────────────── # Comma-separated list of allowed origins for the frontend ("*" for any in dev) CORS_ORIGIN=* diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b6c1100..ee67a31 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.SOROBAN_SIGNER_SECRET_KEY ?? "", }, corsOrigin: process.env.CORS_ORIGIN ?? "*", }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 9a4dc6d..bd3a16b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -9,5 +9,16 @@ export const envValidationSchema = Joi.object({ SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), + // Backend hot-wallet secret used to sign settlement transactions. Required in + // production so the app fails fast on boot rather than at first-submit; optional + // elsewhere so `npm run dev`/tests don't need a real key. Never logged. + SOROBAN_SIGNER_SECRET_KEY: Joi.string() + .pattern(/^S[A-Z2-7]{55}$/) + .when("NODE_ENV", { + is: "production", + then: Joi.required(), + otherwise: Joi.string().allow("").default(""), + }), + CORS_ORIGIN: Joi.string().default("*"), }); diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts new file mode 100644 index 0000000..6af7c21 --- /dev/null +++ b/src/soroban/signer.service.spec.ts @@ -0,0 +1,64 @@ +import { inspect } from "node:util"; +import { ConfigService } from "@nestjs/config"; +import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { SignerService } from "./signer.service"; + +function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { + const values: Record = { + "stellar.signerSecretKey": signerSecretKey, + "stellar.network": network, + }; + return { get: (path: string) => values[path] } as ConfigService; +} + +describe("SignerService", () => { + it("reports unconfigured when no secret is set", () => { + const service = new SignerService(configWith("")); + expect(service.isConfigured()).toBe(false); + }); + + it("throws a clear, secret-free error when signing without a configured key", () => { + const service = new SignerService(configWith("")); + expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNER_SECRET_KEY/); + }); + + it("derives the public key from the configured secret", () => { + const keypair = Keypair.random(); + const service = new SignerService(configWith(keypair.secret())); + + expect(service.isConfigured()).toBe(true); + expect(service.getPublicKey()).toBe(keypair.publicKey()); + }); + + it("maps network config to the right passphrase", () => { + expect(new SignerService(configWith("", "testnet")).getNetworkPassphrase()).toBe(Networks.TESTNET); + expect(new SignerService(configWith("", "futurenet")).getNetworkPassphrase()).toBe(Networks.FUTURENET); + expect(new SignerService(configWith("", "mainnet")).getNetworkPassphrase()).toBe(Networks.PUBLIC); + }); + + it("signs a transaction with the configured key", () => { + const keypair = Keypair.random(); + const service = new SignerService(configWith(keypair.secret())); + + const account = new Account(keypair.publicKey(), "1"); + const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.bumpSequence({ bumpTo: "2" })) + .setTimeout(30) + .build(); + + expect(tx.signatures).toHaveLength(0); + const signed = 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())); + + const secret = keypair.secret(); + expect(String(service)).not.toContain(secret); + expect(JSON.stringify(service)).not.toContain(secret); + expect(inspect(service)).not.toContain(secret); + }); +}); diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts new file mode 100644 index 0000000..9cc2185 --- /dev/null +++ b/src/soroban/signer.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Keypair, Networks, Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; + +const NETWORK_PASSPHRASES: Record = { + testnet: Networks.TESTNET, + futurenet: Networks.FUTURENET, + mainnet: Networks.PUBLIC, +}; + +const REDACTED = "[redacted]"; + +/** + * Holds the backend's Soroban hot-wallet key (env-injected secret, per the + * on-chain settlement ADR) and signs transactions with it. + * + * The raw secret is kept in a private field and is never included in thrown + * errors, logs, or object inspection (see the custom inspect override below). + * Presence is enforced for production by `env.validation.ts`; outside + * production it may be unset, in which case signing operations throw a clear + * error rather than the app failing to boot. + */ +@Injectable() +export class SignerService { + private readonly secretKey: string; + private readonly networkPassphrase: string; + private keypair: Keypair | null = null; + + constructor(configService: ConfigService) { + this.secretKey = configService.get("stellar.signerSecretKey", { infer: true }); + this.networkPassphrase = NETWORK_PASSPHRASES[configService.get("stellar.network", { infer: true })]; + } + + /** Whether a signer secret has been configured. False in dev/test by default. */ + isConfigured(): boolean { + return this.secretKey.length > 0; + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + getPublicKey(): string { + return this.getKeypair().publicKey(); + } + + sign(transaction: T): T { + transaction.sign(this.getKeypair()); + return transaction; + } + + private getKeypair(): Keypair { + if (!this.secretKey) { + throw new Error("Soroban signer is not configured: set SOROBAN_SIGNER_SECRET_KEY"); + } + if (!this.keypair) { + this.keypair = Keypair.fromSecret(this.secretKey); + } + return this.keypair; + } + + toString(): string { + return `SignerService(publicKey=${this.isConfigured() ? this.getPublicKey() : "unconfigured"}, secretKey=${REDACTED})`; + } + + toJSON(): unknown { + return { publicKey: this.isConfigured() ? this.getPublicKey() : null, secretKey: REDACTED }; + } + + [Symbol.for("nodejs.util.inspect.custom")](): string { + return this.toString(); + } +} diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 67d377d..c673341 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; @Module({ controllers: [SorobanController], - providers: [SorobanService], - exports: [SorobanService], + providers: [SorobanService, SignerService], + exports: [SorobanService, SignerService], }) export class SorobanModule {} From 95a6bb0d02405a9e98575143f189c42b57b4ee95 Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 26 Jul 2026 08:08:29 +0000 Subject: [PATCH 2/4] feat: add sequence number management for signing account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the naive per-request getAccount() pattern with an in-process, lock-serialized sequence counter on SignerService. withNextSequence() fetches the account's sequence from the network once, caches it, and hands out a fresh incremented value to each caller in strict call order — so concurrent submissions from the backend's signing account can't race on the same sequence number. The cache is dropped on failure so the next call re-syncs from the network instead of drifting. Closes #30 --- src/soroban/signer.service.spec.ts | 100 ++++++++++++++++++++++++++--- src/soroban/signer.service.ts | 59 ++++++++++++++++- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 6af7c21..62b027e 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { SignerService } from "./signer.service"; +import { SorobanService } from "./soroban.service"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { @@ -12,34 +13,41 @@ function configWith(signerSecretKey: string, network: AppConfig["stellar"]["netw return { get: (path: string) => values[path] } as ConfigService; } +function fakeSorobanService(startingSequence = "100") { + return { + getAccount: jest.fn().mockImplementation(async (publicKey: string) => new Account(publicKey, startingSequence)), + } as unknown as jest.Mocked; +} + describe("SignerService", () => { it("reports unconfigured when no secret is set", () => { - const service = new SignerService(configWith("")); + const service = new SignerService(configWith(""), fakeSorobanService()); expect(service.isConfigured()).toBe(false); }); it("throws a clear, secret-free error when signing without a configured key", () => { - const service = new SignerService(configWith("")); + const service = new SignerService(configWith(""), fakeSorobanService()); expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNER_SECRET_KEY/); }); it("derives the public key from the configured secret", () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret())); + const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); expect(service.isConfigured()).toBe(true); expect(service.getPublicKey()).toBe(keypair.publicKey()); }); it("maps network config to the right passphrase", () => { - expect(new SignerService(configWith("", "testnet")).getNetworkPassphrase()).toBe(Networks.TESTNET); - expect(new SignerService(configWith("", "futurenet")).getNetworkPassphrase()).toBe(Networks.FUTURENET); - expect(new SignerService(configWith("", "mainnet")).getNetworkPassphrase()).toBe(Networks.PUBLIC); + 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); }); it("signs a transaction with the configured key", () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret())); + const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); const account = new Account(keypair.publicKey(), "1"); const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) @@ -54,11 +62,87 @@ describe("SignerService", () => { it("never includes the raw secret in string/JSON/inspect representations", () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret())); + const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); const secret = keypair.secret(); expect(String(service)).not.toContain(secret); expect(JSON.stringify(service)).not.toContain(secret); expect(inspect(service)).not.toContain(secret); }); + + describe("withNextSequence", () => { + 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 first = await service.withNextSequence(async (sequence) => sequence); + const second = await service.withNextSequence(async (sequence) => sequence); + const third = await service.withNextSequence(async (sequence) => sequence); + + expect([first, second, third]).toEqual(["101", "102", "103"]); + expect(soroban.getAccount).toHaveBeenCalledTimes(1); + }); + + 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 results = await Promise.all( + Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)), + ); + + const numeric = results.map(Number).sort((a, b) => a - b); + expect(new Set(numeric).size).toBe(20); // no two callers got the same sequence + expect(numeric).toEqual(Array.from({ length: 20 }, (_, i) => i + 1)); // 1..20, no gaps + }); + + 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 order: number[] = []; + + const slow = service.withNextSequence(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + order.push(1); + }); + const fast = service.withNextSequence(async () => { + order.push(2); + }); + + await Promise.all([slow, fast]); + expect(order).toEqual([1, 2]); // fast waited for slow despite finishing faster on its own + }); + + 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); + + await expect( + service.withNextSequence(async () => { + throw new Error("submission failed"); + }), + ).rejects.toThrow("submission failed"); + + const next = await service.withNextSequence(async (sequence) => sequence); + expect(next).toBe("101"); + expect(soroban.getAccount).toHaveBeenCalledTimes(2); // re-fetched after the failure + }); + + 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 failing = service.withNextSequence(async () => { + throw new Error("boom"); + }); + const following = service.withNextSequence(async (sequence) => sequence); + + await expect(failing).rejects.toThrow("boom"); + // cache was dropped after the failure, so this re-syncs from the network (still "0") and gets "1" + await expect(following).resolves.toBe("1"); + }); + }); }); diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts index 9cc2185..bb1bc15 100644 --- a/src/soroban/signer.service.ts +++ b/src/soroban/signer.service.ts @@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Keypair, Networks, Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; +import { SorobanService } from "./soroban.service"; const NETWORK_PASSPHRASES: Record = { testnet: Networks.TESTNET, @@ -13,13 +14,21 @@ const REDACTED = "[redacted]"; /** * Holds the backend's Soroban hot-wallet key (env-injected secret, per the - * on-chain settlement ADR) and signs transactions with it. + * on-chain settlement ADR), signs transactions with it, and manages that + * account's sequence number. * * The raw secret is kept in a private field and is never included in thrown * errors, logs, or object inspection (see the custom inspect override below). * Presence is enforced for production by `env.validation.ts`; outside * production it may be unset, in which case signing operations throw a clear * error rather than the app failing to boot. + * + * Sequence numbers are managed in-process rather than by calling + * `getAccount()` per request: Soroban requires the *exact* next sequence + * number, and two concurrent submissions from this same signing account that + * each independently fetched "the current sequence" would both try to use + * it, so one would be rejected. `withNextSequence` serializes access with an + * in-process lock and hands out a fresh, already-incremented number per call. */ @Injectable() export class SignerService { @@ -27,7 +36,14 @@ export class SignerService { private readonly networkPassphrase: string; private keypair: Keypair | null = null; - constructor(configService: ConfigService) { + // Chains sequence acquisitions so they run one at a time, in call order. + private sequenceLock: Promise = Promise.resolve(); + private cachedSequence: bigint | null = null; + + constructor( + configService: ConfigService, + private readonly sorobanService: SorobanService, + ) { this.secretKey = configService.get("stellar.signerSecretKey", { infer: true }); this.networkPassphrase = NETWORK_PASSPHRASES[configService.get("stellar.network", { infer: true })]; } @@ -50,6 +66,45 @@ export class SignerService { return transaction; } + /** + * Runs `fn` with the next sequence number for the signing account, holding + * an in-process lock for the duration so no other caller can be handed the + * same sequence number concurrently. + * + * The sequence is fetched from the network once and cached; every call + * after that increments the cached value locally rather than re-fetching. + * If `fn` throws — e.g. the transaction it built was never accepted by the + * network — the cache is dropped so the next call re-syncs from the + * network instead of drifting out of step with the account's real state. + */ + async withNextSequence(fn: (sequence: string) => Promise): Promise { + let releaseLock!: () => void; + const previous = this.sequenceLock; + this.sequenceLock = new Promise((resolve) => { + releaseLock = resolve; + }); + await previous; + + try { + const sequence = await this.nextSequence(); + return await fn(sequence); + } catch (err) { + this.cachedSequence = null; + throw err; + } finally { + releaseLock(); + } + } + + private async nextSequence(): Promise { + if (this.cachedSequence === null) { + const account = await this.sorobanService.getAccount(this.getPublicKey()); + this.cachedSequence = BigInt(account.sequenceNumber()); + } + this.cachedSequence += 1n; + return this.cachedSequence.toString(); + } + private getKeypair(): Keypair { if (!this.secretKey) { throw new Error("Soroban signer is not configured: set SOROBAN_SIGNER_SECRET_KEY"); From 90e99d3594d7d768cc83ea7e62b9aada4062775c Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 26 Jul 2026 08:13:47 +0000 Subject: [PATCH 3/4] feat: add retry/backoff for Soroban transaction submission Adds StellarTxService for building, signing, and submitting Soroban contract-invocation transactions on the signer's account. Submission retries with exponential backoff, capped at maxAttempts, but never resends blindly: before every retry it first checks the transaction's status by hash (the signed envelope is fixed for the call) so a lost response or a poll timeout can't cause a double submission. A confirmed on-chain FAILED status is treated as terminal and is not retried. Exhausted retries and terminal failures both surface as SorobanSubmissionError with a clear message. Also extends SorobanService with prepareTransaction/sendTransaction/ getTransactionStatus, the write-path RPC calls it was missing. Closes #29 --- src/soroban/soroban.module.ts | 5 +- src/soroban/soroban.service.ts | 17 +- src/soroban/stellar-tx.service.spec.ts | 182 +++++++++++++++++++++ src/soroban/stellar-tx.service.ts | 211 +++++++++++++++++++++++++ 4 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 src/soroban/stellar-tx.service.spec.ts create mode 100644 src/soroban/stellar-tx.service.ts diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index c673341..a33d200 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -2,10 +2,11 @@ import { 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"; @Module({ controllers: [SorobanController], - providers: [SorobanService, SignerService], - exports: [SorobanService, SignerService], + providers: [SorobanService, SignerService, StellarTxService], + exports: [SorobanService, SignerService, StellarTxService], }) export class SorobanModule {} diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index 0b32651..487862c 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { SorobanRpc } from "@stellar/stellar-sdk"; +import { FeeBumpTransaction, SorobanRpc, Transaction } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; @Injectable() @@ -27,4 +27,19 @@ export class SorobanService { getAccount(publicKey: string) { return this.server.getAccount(publicKey); } + + /** Simulates `tx` and returns it assembled with the resulting footprint/resource fees, ready to sign. */ + prepareTransaction(tx: Transaction): Promise { + return this.server.prepareTransaction(tx); + } + + /** Submits a signed transaction. Does not wait for it to land — see `getTransactionStatus`. */ + sendTransaction(tx: Transaction | FeeBumpTransaction): Promise { + return this.server.sendTransaction(tx); + } + + /** Looks up a submitted transaction by hash; status is NOT_FOUND until it lands. */ + getTransactionStatus(hash: string): Promise { + return this.server.getTransaction(hash); + } } diff --git a/src/soroban/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts new file mode 100644 index 0000000..5d59020 --- /dev/null +++ b/src/soroban/stellar-tx.service.spec.ts @@ -0,0 +1,182 @@ +import { Keypair, Networks, SorobanRpc, Transaction } from "@stellar/stellar-sdk"; +import { SignerService } from "./signer.service"; +import { SorobanService } from "./soroban.service"; +import { SorobanSubmissionError, StellarTxService, nativeArgs } from "./stellar-tx.service"; + +const CONTRACT_ID = "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"; +const FAST_OPTS = { baseBackoffMs: 1, maxBackoffMs: 1, pollIntervalMs: 1, pollTimeoutMs: 20 }; + +function fakeSigner(keypair = Keypair.random()) { + return { + getPublicKey: jest.fn(() => keypair.publicKey()), + getNetworkPassphrase: jest.fn(() => Networks.TESTNET), + sign: jest.fn((tx: Transaction) => tx), + withNextSequence: jest.fn(async (fn: (sequence: string) => Promise) => fn("101")), + } as unknown as jest.Mocked; +} + +function fakeSoroban() { + return { + prepareTransaction: jest.fn(async (tx: Transaction) => tx), + sendTransaction: jest.fn(), + getTransactionStatus: jest.fn(), + } as unknown as jest.Mocked; +} + +function successResponse(): SorobanRpc.Api.GetSuccessfulTransactionResponse { + return { + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + latestLedger: 1, + latestLedgerCloseTime: 1, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + ledger: 1, + createdAt: 1, + applicationOrder: 1, + feeBump: false, + } as SorobanRpc.Api.GetSuccessfulTransactionResponse; +} + +function failedResponse(): SorobanRpc.Api.GetFailedTransactionResponse { + return { ...successResponse(), status: SorobanRpc.Api.GetTransactionStatus.FAILED } as SorobanRpc.Api.GetFailedTransactionResponse; +} + +function notFoundResponse(): SorobanRpc.Api.GetMissingTransactionResponse { + return { + status: SorobanRpc.Api.GetTransactionStatus.NOT_FOUND, + latestLedger: 1, + latestLedgerCloseTime: 1, + oldestLedger: 1, + oldestLedgerCloseTime: 1, + }; +} + +describe("StellarTxService", () => { + describe("buildContractInvocation", () => { + it("builds a transaction sourced from the signer's account at the given sequence", async () => { + const keypair = Keypair.random(); + const signer = fakeSigner(keypair); + const service = new StellarTxService(fakeSoroban(), signer); + + const tx = await service.buildContractInvocation({ + contractId: CONTRACT_ID, + method: "create_intent", + args: nativeArgs("intent-1"), + sequence: "101", + }); + + expect(tx.source).toBe(keypair.publicKey()); + expect(tx.sequence).toBe("101"); // not "102" -- Account must be built one behind the target + expect(tx.operations).toHaveLength(1); + expect(tx.operations[0].type).toBe("invokeHostFunction"); + }); + }); + + describe("signAndSubmit", () => { + it("resolves once the transaction is confirmed on the first attempt", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "abc", latestLedger: 1, latestLedgerCloseTime: 1 }); + soroban.getTransactionStatus.mockResolvedValue(successResponse()); + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + const result = await service.signAndSubmit(tx, FAST_OPTS); + + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + expect(soroban.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("checks tx status before resubmitting after a transient failure, instead of blindly resending", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction + .mockRejectedValueOnce(new Error("ECONNRESET")) + .mockResolvedValueOnce({ status: "PENDING", hash: "abc", latestLedger: 1, latestLedgerCloseTime: 1 }); + soroban.getTransactionStatus + .mockResolvedValueOnce(notFoundResponse()) // idempotency check before 2nd send: not landed yet + .mockResolvedValueOnce(successResponse()); // poll after 2nd send + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + const result = await service.signAndSubmit(tx, FAST_OPTS); + + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + expect(soroban.sendTransaction).toHaveBeenCalledTimes(2); + expect(soroban.getTransactionStatus).toHaveBeenCalledTimes(2); + }); + + it("does not resend when the idempotency check finds the tx already landed", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockRejectedValueOnce(new Error("timed out, unknown outcome")); + soroban.getTransactionStatus.mockResolvedValueOnce(successResponse()); // already landed from the "failed" attempt + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + const result = await service.signAndSubmit(tx, FAST_OPTS); + + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + expect(soroban.sendTransaction).toHaveBeenCalledTimes(1); // never resent + }); + + it("caps retry attempts and throws a clear error after exhaustion", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockRejectedValue(new Error("TRY_AGAIN_LATER")); + soroban.getTransactionStatus.mockResolvedValue(notFoundResponse()); + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + await expect(service.signAndSubmit(tx, { ...FAST_OPTS, maxAttempts: 3 })).rejects.toMatchObject({ + name: "SorobanSubmissionError", + attempts: 3, + }); + expect(soroban.sendTransaction).toHaveBeenCalledTimes(3); + }); + + it("throws immediately on a confirmed on-chain FAILED status, without burning the retry budget", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "abc", latestLedger: 1, latestLedgerCloseTime: 1 }); + soroban.getTransactionStatus.mockResolvedValue(failedResponse()); + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + await expect(service.signAndSubmit(tx, { ...FAST_OPTS, maxAttempts: 5 })).rejects.toMatchObject({ + name: "SorobanSubmissionError", + terminal: true, + }); + expect(soroban.sendTransaction).toHaveBeenCalledTimes(1); // FAILED is terminal, not retried + }); + + it("times out waiting for confirmation if it never resolves out of NOT_FOUND", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "abc", latestLedger: 1, latestLedgerCloseTime: 1 }); + soroban.getTransactionStatus.mockResolvedValue(notFoundResponse()); + + const service = new StellarTxService(soroban, fakeSigner()); + const tx = await service.buildContractInvocation({ contractId: CONTRACT_ID, method: "ping", sequence: "101" }); + + await expect(service.signAndSubmit(tx, { ...FAST_OPTS, maxAttempts: 1 })).rejects.toThrow(SorobanSubmissionError); + }); + }); + + describe("invokeContract", () => { + it("reserves a sequence, builds, and submits under the signer's lock", async () => { + const soroban = fakeSoroban(); + soroban.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "abc", latestLedger: 1, latestLedgerCloseTime: 1 }); + soroban.getTransactionStatus.mockResolvedValue(successResponse()); + const signer = fakeSigner(); + + const service = new StellarTxService(soroban, signer); + const result = await service.invokeContract( + { contractId: CONTRACT_ID, method: "create_intent", args: nativeArgs("intent-1") }, + FAST_OPTS, + ); + + expect(signer.withNextSequence).toHaveBeenCalledTimes(1); + expect(result.status).toBe(SorobanRpc.Api.GetTransactionStatus.SUCCESS); + }); + }); +}); diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts new file mode 100644 index 0000000..7ebf4a5 --- /dev/null +++ b/src/soroban/stellar-tx.service.ts @@ -0,0 +1,211 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + Account, + BASE_FEE, + Contract, + nativeToScVal, + SorobanRpc, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { SignerService } from "./signer.service"; +import { SorobanService } from "./soroban.service"; + +export interface ContractInvocation { + contractId: string; + method: string; + args?: xdr.ScVal[]; + sequence: string; + fee?: string; + timeoutSeconds?: number; +} + +export interface SubmitOptions { + /** Maximum number of submit attempts before giving up. */ + maxAttempts?: number; + /** Base delay for exponential backoff between attempts, in ms. */ + baseBackoffMs?: number; + /** Ceiling on the backoff delay, in ms. */ + maxBackoffMs?: number; + /** How often to poll for a submitted transaction's result, in ms. */ + pollIntervalMs?: number; + /** How long to keep polling before giving up on confirmation, in ms. */ + pollTimeoutMs?: number; +} + +export interface SubmitResult { + hash: string; + status: SorobanRpc.Api.GetTransactionStatus; + response: SorobanRpc.Api.GetSuccessfulTransactionResponse; +} + +const DEFAULT_MAX_ATTEMPTS = 5; +const DEFAULT_BASE_BACKOFF_MS = 300; +const DEFAULT_MAX_BACKOFF_MS = 5_000; +const DEFAULT_POLL_INTERVAL_MS = 1_000; +const DEFAULT_POLL_TIMEOUT_MS = 30_000; + +/** Thrown once retries are exhausted or the transaction is confirmed as failed on-chain. */ +export class SorobanSubmissionError extends Error { + constructor( + message: string, + readonly hash: string | null, + readonly attempts: number, + /** True for a definitive on-chain outcome (e.g. FAILED) that must not be retried. */ + readonly terminal = false, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "SorobanSubmissionError"; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Converts JS values to ScVal via the SDK's type inference, for callers that don't need explicit XDR types. */ +export function nativeArgs(...values: unknown[]): xdr.ScVal[] { + return values.map((value) => nativeToScVal(value)); +} + +/** + * Builds and submits Soroban contract-invocation transactions. + * + * Submission is not a naive "retry on any error": a transient RPC failure + * here can mean the transaction was actually accepted by the network before + * the failure happened (e.g. the response to `sendTransaction` was lost, or + * a later confirmation poll times out). Blindly resubmitting in that case + * risks a double submission. So before ever resending, we check the + * transaction's status by hash — the signed envelope (and therefore its + * hash) is fixed for the lifetime of one `signAndSubmit` call, only the + * *attempt* to get it accepted is retried. + */ +@Injectable() +export class StellarTxService { + private readonly logger = new Logger(StellarTxService.name); + + constructor( + private readonly sorobanService: SorobanService, + private readonly signerService: SignerService, + ) {} + + /** + * Builds an unsigned contract-invocation transaction against the signer's + * account, using `invocation.sequence` as the resulting transaction's + * sequence number (e.g. the value handed out by `SignerService.withNextSequence`). + */ + async buildContractInvocation(invocation: ContractInvocation): Promise { + // `Account`'s sequence is the account's *current* sequence, and + // TransactionBuilder increments it by one for the built tx — so to make + // the tx land on `invocation.sequence`, the Account must be constructed + // one behind it. + const accountSequence = (BigInt(invocation.sequence) - 1n).toString(); + const source = new Account(this.signerService.getPublicKey(), accountSequence); + const contract = new Contract(invocation.contractId); + + return new TransactionBuilder(source, { + fee: invocation.fee ?? BASE_FEE, + networkPassphrase: this.signerService.getNetworkPassphrase(), + }) + .addOperation(contract.call(invocation.method, ...(invocation.args ?? []))) + .setTimeout(invocation.timeoutSeconds ?? 30) + .build(); + } + + /** + * Reserves the signing account's next sequence number, builds a contract + * invocation on it, and signs and submits it — all under the same + * sequence-lock, so this is safe to call concurrently. + */ + async invokeContract( + invocation: Omit, + opts?: SubmitOptions, + ): Promise { + return this.signerService.withNextSequence(async (sequence) => { + const tx = await this.buildContractInvocation({ ...invocation, sequence }); + return this.signAndSubmit(tx, opts); + }); + } + + /** + * Simulates, signs, and submits `tx`, retrying transient submission + * failures with exponential backoff. Always checks whether the transaction + * already landed before resending it. Caps attempts and throws + * `SorobanSubmissionError` with a clear message once exhausted. + */ + async signAndSubmit(tx: Transaction, opts: SubmitOptions = {}): Promise { + const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS; + const maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS; + + const prepared = await this.sorobanService.prepareTransaction(tx); + const signed = this.signerService.sign(prepared); + const hash = signed.hash().toString("hex"); + + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + if (attempt > 1) { + // Idempotency guard: a prior attempt in this same call may have + // already been accepted even though it appeared to fail (lost + // response, poll timeout, etc). Never resend blindly. + const existing = await this.sorobanService.getTransactionStatus(hash); + if (existing.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + return this.finalize(existing, hash); + } + } + + const sendResult = await this.sorobanService.sendTransaction(signed); + if (sendResult.status === "ERROR") { + throw new Error(`Soroban rejected submission: ${JSON.stringify(sendResult.errorResult ?? sendResult.status)}`); + } + + return await this.pollForResult(hash, opts); + } catch (err) { + // A definitive on-chain outcome (e.g. FAILED) is not a transient + // submission problem — retrying it would just confirm the same + // failure again, wasting the whole attempt budget. + if (err instanceof SorobanSubmissionError && err.terminal) throw err; + + lastError = err; + this.logger.warn(`Soroban submission attempt ${attempt}/${maxAttempts} failed for ${hash}: ${(err as Error).message}`); + if (attempt === maxAttempts) break; + await sleep(Math.min(baseBackoffMs * 2 ** (attempt - 1), maxBackoffMs)); + } + } + + throw new SorobanSubmissionError( + `Soroban transaction submission failed after ${maxAttempts} attempts (hash=${hash})`, + hash, + maxAttempts, + false, + { cause: lastError }, + ); + } + + private async pollForResult(hash: string, opts: SubmitOptions): Promise { + const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const pollTimeoutMs = opts.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; + const deadline = Date.now() + pollTimeoutMs; + + for (;;) { + const status = await this.sorobanService.getTransactionStatus(hash); + if (status.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + return this.finalize(status, hash); + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for confirmation of Soroban transaction ${hash}`); + } + await sleep(pollIntervalMs); + } + } + + private finalize(status: SorobanRpc.Api.GetTransactionResponse, hash: string): SubmitResult { + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new SorobanSubmissionError(`Soroban transaction failed on-chain (hash=${hash})`, hash, 1, true); + } + return { hash, status: status.status, response: status as SorobanRpc.Api.GetSuccessfulTransactionResponse }; + } +} From 5e05523ff822d732b6bda99d78aedf2b9151e42c Mon Sep 17 00:00:00 2001 From: chideraisiguzor Date: Sun, 26 Jul 2026 08:19:54 +0000 Subject: [PATCH 4/4] feat: register intents on-chain via settlement contract IntentsService.create() now calls the settlement contract through StellarTxService.invokeContract() when ONCHAIN_INTENTS_ENABLED is on, gated behind a rollout flag (default off) that falls back to the existing pure in-memory behavior. IntentsController.create()'s response shape is unchanged. When the flag is on, a missing SETTLEMENT_CONTRACT_ID or a failed on-chain submission surfaces as a 503 and the intent is not added to the local store -- no phantom off-chain-only intents. Argument encoding for the contract call is provisional pending the on-chain settlement ADR and typed contract bindings, both still open. Closes #22 --- .env.example | 5 ++ src/config/configuration.ts | 2 + src/config/env.validation.ts | 6 ++ src/intents/intents.controller.ts | 4 +- src/intents/intents.module.ts | 3 +- src/intents/intents.service.spec.ts | 107 +++++++++++++++++++++++++++- src/intents/intents.service.ts | 64 ++++++++++++++++- 7 files changed, 182 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 1b2f19c..72798ef 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ SOLVER_REGISTRY_CONTRACT_ID= # transactions. Required when NODE_ENV=production. Never commit a real value. SOROBAN_SIGNER_SECRET_KEY= +# Rollout flag: register intents on-chain via the settlement contract instead +# of the in-memory store. Requires SETTLEMENT_CONTRACT_ID and +# SOROBAN_SIGNER_SECRET_KEY to be set. Defaults to off. +ONCHAIN_INTENTS_ENABLED=false + # ─── CORS ──────────────────────────────────────────────────────────────────── # Comma-separated list of allowed origins for the frontend ("*" for any in dev) CORS_ORIGIN=* diff --git a/src/config/configuration.ts b/src/config/configuration.ts index ee67a31..35910b7 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -8,6 +8,7 @@ export interface AppConfig { solverRegistryContractId: string; signerSecretKey: string; }; + onchainIntentsEnabled: boolean; corsOrigin: string; } @@ -21,5 +22,6 @@ export default (): AppConfig => ({ solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", signerSecretKey: process.env.SOROBAN_SIGNER_SECRET_KEY ?? "", }, + onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", corsOrigin: process.env.CORS_ORIGIN ?? "*", }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index bd3a16b..210ffbc 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -20,5 +20,11 @@ export const envValidationSchema = Joi.object({ otherwise: Joi.string().allow("").default(""), }), + // Rollout flag: when false (default), IntentsService.create() stays fully + // in-memory as before. Flip on once the settlement contract + signer are + // ready in a given environment; flip back off to fall back instantly if + // on-chain registration misbehaves. + ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false), + 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..e5f7904 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -4,9 +4,10 @@ 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, SorobanModule], controllers: [IntentsController], providers: [IntentsService, IntentsGateway, IntentsSweeperService], exports: [IntentsService], diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index 7bbba6d..4787e73 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -1,10 +1,40 @@ +import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; +const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +function fakeConfig(overrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}) { + const values: Record = { + onchainIntentsEnabled: overrides.onchainIntentsEnabled ?? false, + "stellar.settlementContractId": overrides.settlementContractId ?? "", + }; + return { get: (path: string) => values[path] } as ConfigService; +} + +function fakeStellarTxService() { + return { invokeContract: jest.fn() } as unknown as jest.Mocked; +} + +function validCreateData() { + return { + user: Keypair.random().publicKey(), + srcChain: "ethereum" as const, + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" as const }, + srcAmount: "1000000", + dstToken: { contract: VALID_CONTRACT_ID, symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: Math.floor(Date.now() / 1000) + 1800, + }; +} + describe("IntentsService", () => { let service: IntentsService; beforeEach(() => { - service = new IntentsService(); + service = new IntentsService(fakeConfig(), fakeStellarTxService()); }); it("seeds 5 intents on construction", () => { @@ -18,10 +48,10 @@ describe("IntentsService", () => { } }); - it("create adds an open intent with a generated id", () => { + it("create adds an open intent with a generated id", async () => { const before = service.getAll().length; const deadline = Math.floor(Date.now() / 1000) + 1800; - const intent = service.create({ + const intent = await service.create({ user: "GTEST...0000", srcChain: "ethereum", srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, @@ -66,4 +96,75 @@ describe("IntentsService", () => { expect(intent.state).toBe("filled"); } }); + + describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => { + it("stays fully in-memory when the flag is off, never touching StellarTxService", async () => { + const stellarTxService = fakeStellarTxService(); + const service = new IntentsService(fakeConfig({ onchainIntentsEnabled: false }), stellarTxService); + + const intent = await service.create(validCreateData()); + + expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); + expect(service.get(intent.intentId)).toEqual(intent); + }); + + it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => { + const stellarTxService = fakeStellarTxService(); + stellarTxService.invokeContract.mockResolvedValue({ hash: "deadbeef", status: "SUCCESS" } as never); + const service = new IntentsService( + fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }), + stellarTxService, + ); + + const data = validCreateData(); + const intent = await service.create(data); + + expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1); + const call = stellarTxService.invokeContract.mock.calls[0][0]; + expect(call.contractId).toBe(VALID_CONTRACT_ID); + expect(call.method).toBe("create_intent"); + + // response shape is unchanged: no fields added/removed relative to the in-memory path + expect(Object.keys(intent).sort()).toEqual( + Object.keys({ + intentId: "", + user: "", + srcChain: "", + srcToken: "", + srcAmount: "", + dstToken: "", + minDstAmount: "", + state: "", + createdAt: 0, + deadline: 0, + }).sort(), + ); + expect(service.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 = new IntentsService(fakeConfig({ onchainIntentsEnabled: true }), stellarTxService); + const before = service.getAll().length; + + await expect(service.create(validCreateData())).rejects.toMatchObject({ + message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"), + }); + expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); + expect(service.getAll()).toHaveLength(before); + }); + + it("rejects and does not create the intent when the on-chain call fails", async () => { + const stellarTxService = fakeStellarTxService(); + stellarTxService.invokeContract.mockRejectedValue(new Error("submission failed after 5 attempts")); + const service = new IntentsService( + fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }), + stellarTxService, + ); + const before = service.getAll().length; + + await expect(service.create(validCreateData())).rejects.toThrow(/settlement contract/i); + expect(service.getAll()).toHaveLength(before); + }); + }); }); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 86ed406..3e9c3c2 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -1,17 +1,25 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { v4 as uuidv4 } from "uuid"; +import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; import { Intent, IntentState } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; +import { AppConfig } from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; @Injectable() export class IntentsService { + private readonly logger = new Logger(IntentsService.name); private readonly intents = new Map(); - constructor() { + constructor( + private readonly configService: ConfigService, + private readonly stellarTxService: StellarTxService, + ) { this.seed(); } - create(data: Omit): Intent { + async create(data: Omit): Promise { const now = Math.floor(Date.now() / 1000); const intent: Intent = { ...data, @@ -20,10 +28,60 @@ export class IntentsService { createdAt: now, deadline: data.deadline ?? now + 1800, }; + + if (this.configService.get("onchainIntentsEnabled", { infer: true })) { + await this.registerOnChain(intent); + } + this.intents.set(intent.intentId, intent); return intent; } + /** + * Registers `intent` with the settlement contract. Only called when + * ONCHAIN_INTENTS_ENABLED is on; while that flag is off, create() stays + * fully in-memory (the rollout fallback). + * + * The exact call — method name and argument encoding — is provisional: + * the settlement contract's interface isn't finalized yet (see the + * on-chain settlement ADR and the typed contract bindings work), so this + * uses the SDK's native-value conversion rather than hand-written XDR + * types that would need to change the moment real bindings land. + */ + private async registerOnChain(intent: Intent): Promise { + const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); + if (!contractId) { + throw new ServiceUnavailableException( + "On-chain intent registration is enabled but SETTLEMENT_CONTRACT_ID is not configured", + ); + } + + try { + const result = await this.stellarTxService.invokeContract({ + contractId, + method: "create_intent", + args: this.buildCreateIntentArgs(intent), + }); + this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`); + } catch (err) { + this.logger.error(`Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`); + throw new ServiceUnavailableException("Failed to register intent with the settlement contract"); + } + } + + private buildCreateIntentArgs(intent: Intent): xdr.ScVal[] { + return [ + nativeToScVal(intent.intentId, { type: "string" }), + new Address(intent.user).toScVal(), + nativeToScVal(intent.srcChain, { type: "symbol" }), + nativeToScVal(intent.srcToken.address, { type: "string" }), + nativeToScVal(BigInt(intent.srcAmount), { type: "i128" }), + new Address(intent.dstToken.contract).toScVal(), + nativeToScVal(BigInt(intent.minDstAmount), { type: "i128" }), + nativeToScVal(intent.deadline, { type: "u64" }), + ]; + } + get(id: string): Intent | undefined { return this.intents.get(id); }