diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 20d8e89..5a76f72 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -28,6 +28,7 @@ export interface AppConfig { // a placeholder. Never log this value. signingKey: string; }; + onchainIntentsEnabled: boolean; corsOrigin: string; } @@ -41,5 +42,6 @@ export default (): AppConfig => ({ solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", signingKey: process.env.SOROBAN_SIGNING_KEY ?? "", }, + onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", corsOrigin: process.env.CORS_ORIGIN ?? "*", }); 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.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); } diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts new file mode 100644 index 0000000..62b027e --- /dev/null +++ b/src/soroban/signer.service.spec.ts @@ -0,0 +1,148 @@ +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"; +import { SorobanService } from "./soroban.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; +} + +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(""), fakeSorobanService()); + expect(service.isConfigured()).toBe(false); + }); + + it("throws a clear, secret-free error when signing without a configured key", () => { + 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()), fakeSorobanService()); + + expect(service.isConfigured()).toBe(true); + expect(service.getPublicKey()).toBe(keypair.publicKey()); + }); + + it("maps network config to the right passphrase", () => { + 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()), fakeSorobanService()); + + 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()), 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 new file mode 100644 index 0000000..bb1bc15 --- /dev/null +++ b/src/soroban/signer.service.ts @@ -0,0 +1,129 @@ +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, + 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), 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 { + private readonly secretKey: string; + private readonly networkPassphrase: string; + private keypair: Keypair | null = null; + + // 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 })]; + } + + /** 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; + } + + /** + * 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"); + } + 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(); + } +}