From 96446258872414992a304d24a3b217d5b2900cf9 Mon Sep 17 00:00:00 2001 From: Isenewo Oluwaseyi Ephraim Date: Mon, 30 Mar 2026 12:23:45 +0100 Subject: [PATCH] feat(backend): introduce transaction submission abstraction (draft) Body: - add transaction submission service for Horizon/RPC flows - support external_signer and backend_sign modes - enforce signer env trust model (dev seed only, KMS required in non-dev backend signing) - add mocked tests for success and failure paths (including redaction) - document signer modes and security model in README --- README.md | 19 ++ src/config/env.test.ts | 44 +++++ src/config/env.ts | 24 +++ src/services/transactionService.test.ts | 182 ++++++++++++++++++++ src/services/transactionService.ts | 220 ++++++++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 src/services/transactionService.test.ts create mode 100644 src/services/transactionService.ts diff --git a/README.md b/README.md index b3159b4..f157684 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,25 @@ Security notes: - Replay protection is enforced by deduplicating `eventId` values in the ingestion service. - Duplicate deliveries are treated as safe no-ops and return `202 Accepted`. +## Transaction Submission Service (Horizon/RPC) + +The backend now includes a transaction submission abstraction in `src/services/transactionService.ts`. + +Signer modes: +- `external_signer` (default): backend prepares/accepts XDR and returns it for signing by an external signer service. +- `backend_sign`: backend signs and submits transactions. + +Trust model: +- Production: signing keys must be KMS-managed (`TX_SIGNING_KMS_KEY_ID`) when `TX_SIGNER_MODE=backend_sign`. +- Development only: `TX_SIGNING_SEED` can be used for local testing. +- Seed phrases are never logged and are redacted from surfaced service errors. + +Relevant env vars: +- `TX_SIGNER_MODE` = `backend_sign` or `external_signer` +- `TX_SIGNING_KMS_KEY_ID` for production backend signing +- `TX_SIGNING_SEED` for development-only backend signing +- `TX_EXTERNAL_SIGNER_URL` for external signer workflows + ## API Versioning Policy All new features and endpoints must be mounted under the `/api/v1` prefix. diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 605caee..32615b4 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -9,6 +9,7 @@ describe("Environment Configuration Schema", () => { DATABASE_URL: "postgres://localhost:5432/db", JWT_SECRET: "a_very_long_secret_that_is_at_least_32_characters", RPC_URL: "https://api.mainnet-beta.solana.com", + TX_SIGNER_MODE: "external_signer", NODE_ENV: "development", }; @@ -18,6 +19,7 @@ describe("Environment Configuration Schema", () => { if (result.success) { expect(result.data.PORT).toBe(3001); expect(result.data.NODE_ENV).toBe("development"); + expect(result.data.TX_SIGNER_MODE).toBe("external_signer"); } }); @@ -69,6 +71,48 @@ describe("Environment Configuration Schema", () => { } }); + it("should default TX_SIGNER_MODE to external_signer if missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { TX_SIGNER_MODE, ...envWithoutSignerMode } = validEnv; + const result = envSchema.safeParse(envWithoutSignerMode); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.TX_SIGNER_MODE).toBe("external_signer"); + } + }); + + it("should fail if TX_SIGNING_SEED is set outside development", () => { + const invalidEnv = { + ...validEnv, + NODE_ENV: "production", + TX_SIGNING_SEED: "SSECRET", + }; + + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.TX_SIGNING_SEED).toContain( + "TX_SIGNING_SEED is only allowed in development.", + ); + } + }); + + it("should fail in production backend_sign mode without KMS key id", () => { + const invalidEnv = { + ...validEnv, + NODE_ENV: "production", + TX_SIGNER_MODE: "backend_sign", + }; + + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.TX_SIGNING_KMS_KEY_ID).toContain( + "TX_SIGNING_KMS_KEY_ID is required outside development when TX_SIGNER_MODE=backend_sign.", + ); + } + }); + describe("validateEnv function", () => { it("should throw in test environment if validation fails", () => { expect(() => validateEnv({})).toThrow("Invalid environment variables"); diff --git a/src/config/env.ts b/src/config/env.ts index 9e6cfce..b88bcc0 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,7 +9,31 @@ export const envSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), RPC_URL: z.string().url(), + TX_SIGNER_MODE: z.enum(["backend_sign", "external_signer"]).default("external_signer"), + TX_SIGNING_SEED: z.string().optional(), + TX_SIGNING_KMS_KEY_ID: z.string().optional(), + TX_EXTERNAL_SIGNER_URL: z.string().url().optional(), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), +}).superRefine((value, ctx) => { + if (value.TX_SIGNING_SEED && value.NODE_ENV !== "development") { + ctx.addIssue({ + code: "custom", + path: ["TX_SIGNING_SEED"], + message: "TX_SIGNING_SEED is only allowed in development.", + }); + } + + if ( + value.TX_SIGNER_MODE === "backend_sign" + && value.NODE_ENV !== "development" + && !value.TX_SIGNING_KMS_KEY_ID + ) { + ctx.addIssue({ + code: "custom", + path: ["TX_SIGNING_KMS_KEY_ID"], + message: "TX_SIGNING_KMS_KEY_ID is required outside development when TX_SIGNER_MODE=backend_sign.", + }); + } }); export type Env = z.infer; diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts new file mode 100644 index 0000000..0d71ef4 --- /dev/null +++ b/src/services/transactionService.test.ts @@ -0,0 +1,182 @@ +import { + TransactionNetworkClient, + TransactionService, + TransactionSigner, + buildTransactionServiceConfigFromEnv, +} from "./transactionService"; + +declare const describe: (name: string, run: () => void) => void; +declare const it: (name: string, run: () => void | Promise) => void; +declare const expect: { + (value: unknown): { + toEqual: (expected: unknown) => void; + toBe: (expected: unknown) => void; + toContain: (expected: string) => void; + toHaveBeenCalledWith: (...args: unknown[]) => void; + toHaveBeenCalled: () => void; + not: { + toContain: (expected: string) => void; + toHaveBeenCalled: () => void; + }; + rejects: { + toThrow: (expected: string) => Promise; + }; + }; +}; +declare const jest: { + fn: () => { + (...args: unknown[]): unknown; + mockResolvedValue: (value: unknown) => void; + mockRejectedValue: (value: unknown) => void; + }; +}; + +describe("TransactionService", () => { + const createMockNetwork = () => ({ + submitSignedTransaction: jest.fn(), + }); + + const createMockSigner = () => ({ + signTransactionXdr: jest.fn(), + }); + + it("submits signed transaction in backend_sign mode", async () => { + const network = createMockNetwork(); + const signer = createMockSigner(); + + signer.signTransactionXdr.mockResolvedValue("signed-xdr"); + network.submitSignedTransaction.mockResolvedValue({ hash: "abc123", ledger: 10 }); + + const service = new TransactionService( + { + mode: "backend_sign", + horizonUrl: "https://horizon-testnet.stellar.org", + nodeEnv: "development", + }, + network as TransactionNetworkClient, + signer as TransactionSigner, + ); + + const result = await service.submitTransaction({ unsignedXdr: "unsigned-xdr" }); + + expect(signer.signTransactionXdr).toHaveBeenCalledWith("unsigned-xdr"); + expect(network.submitSignedTransaction).toHaveBeenCalledWith("signed-xdr"); + expect(result).toEqual({ + status: "submitted", + mode: "backend_sign", + hash: "abc123", + ledger: 10, + signedXdr: "signed-xdr", + }); + }); + + it("returns unsigned XDR instructions in external_signer mode", async () => { + const network = createMockNetwork(); + + const service = new TransactionService( + { + mode: "external_signer", + horizonUrl: "https://horizon-testnet.stellar.org", + nodeEnv: "production", + externalSignerUrl: "https://signer.internal", + }, + network as TransactionNetworkClient, + ); + + const result = await service.submitTransaction({ unsignedXdr: "unsigned-xdr" }); + + expect(network.submitSignedTransaction).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: "awaiting_external_signature", + mode: "external_signer", + unsignedXdr: "unsigned-xdr", + signerUrl: "https://signer.internal", + }); + }); + + it("submits externally signed XDR", async () => { + const network = createMockNetwork(); + network.submitSignedTransaction.mockResolvedValue({ hash: "hash-1" }); + + const service = new TransactionService( + { + mode: "external_signer", + horizonUrl: "https://horizon-testnet.stellar.org", + nodeEnv: "test", + }, + network as TransactionNetworkClient, + ); + + const result = await service.submitSignedXdr("signed-xdr", "external_signer"); + + expect(result).toEqual({ + status: "submitted", + mode: "external_signer", + hash: "hash-1", + signedXdr: "signed-xdr", + ledger: undefined, + }); + }); + + it("fails when backend_sign mode has no signer", async () => { + const network = createMockNetwork(); + + const service = new TransactionService( + { + mode: "backend_sign", + horizonUrl: "https://horizon-testnet.stellar.org", + nodeEnv: "test", + }, + network as TransactionNetworkClient, + ); + + await expect(service.submitTransaction({ unsignedXdr: "unsigned-xdr" })).rejects.toThrow( + "Signer is required when TX_SIGNER_MODE=backend_sign", + ); + }); + + it("redacts seed phrase from failure error messages", async () => { + const network = createMockNetwork(); + const signer = createMockSigner(); + const seed = "SDEV-SEED-MUST-NOT-LEAK"; + + signer.signTransactionXdr.mockRejectedValue(new Error(`bad seed ${seed}`)); + + const service = new TransactionService( + { + mode: "backend_sign", + horizonUrl: "https://horizon-testnet.stellar.org", + nodeEnv: "development", + signingSeed: seed, + }, + network as TransactionNetworkClient, + signer as TransactionSigner, + ); + + await expect(service.submitTransaction({ unsignedXdr: "unsigned-xdr" })).rejects.toThrow( + "Failed to sign and submit transaction", + ); + + try { + await service.submitTransaction({ unsignedXdr: "unsigned-xdr" }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + expect(message).not.toContain(seed); + expect(message).toContain("[REDACTED]"); + } + }); + + it("maps env config into service config", () => { + const config = buildTransactionServiceConfigFromEnv({ + RPC_URL: "https://horizon-testnet.stellar.org", + NODE_ENV: "test", + TX_SIGNER_MODE: "external_signer", + TX_SIGNING_SEED: undefined, + TX_SIGNING_KMS_KEY_ID: undefined, + TX_EXTERNAL_SIGNER_URL: "https://signer.internal", + }); + + expect(config.mode).toBe("external_signer"); + expect(config.horizonUrl).toBe("https://horizon-testnet.stellar.org"); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts new file mode 100644 index 0000000..7bf2891 --- /dev/null +++ b/src/services/transactionService.ts @@ -0,0 +1,220 @@ +export type TransactionSignerMode = "backend_sign" | "external_signer"; + +export interface TransactionServiceConfig { + mode: TransactionSignerMode; + horizonUrl: string; + nodeEnv: "development" | "production" | "test"; + signingSeed?: string; + signingKmsKeyId?: string; + externalSignerUrl?: string; +} + +export interface TransactionSigner { + signTransactionXdr(unsignedXdr: string): Promise; +} + +export interface TransactionSubmissionResult { + hash: string; + ledger?: number; + raw?: unknown; +} + +export interface TransactionNetworkClient { + submitSignedTransaction(signedXdr: string): Promise; +} + +export interface SubmitTransactionInput { + unsignedXdr: string; +} + +export type SubmitTransactionOutcome = + | { + status: "submitted"; + mode: TransactionSignerMode; + hash: string; + ledger?: number; + signedXdr: string; + } + | { + status: "awaiting_external_signature"; + mode: "external_signer"; + unsignedXdr: string; + signerUrl?: string; + }; + +export const buildTransactionServiceConfigFromEnv = ( + config: { + RPC_URL: string; + NODE_ENV: "development" | "production" | "test"; + TX_SIGNER_MODE: TransactionSignerMode; + TX_SIGNING_SEED?: string; + TX_SIGNING_KMS_KEY_ID?: string; + TX_EXTERNAL_SIGNER_URL?: string; + }, +): TransactionServiceConfig => ({ + mode: config.TX_SIGNER_MODE, + horizonUrl: config.RPC_URL, + nodeEnv: config.NODE_ENV, + signingSeed: config.TX_SIGNING_SEED, + signingKmsKeyId: config.TX_SIGNING_KMS_KEY_ID, + externalSignerUrl: config.TX_EXTERNAL_SIGNER_URL, +}); + +export class HorizonNetworkClient implements TransactionNetworkClient { + private readonly baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + async submitSignedTransaction(signedXdr: string): Promise { + const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl; + const endpoint = `${base}/transactions`; + const body = `tx=${encodeURIComponent(signedXdr)}`; + + const globalFetch = ( + globalThis as unknown as { + fetch?: (url: string, init?: { + method?: string; + headers?: Record; + body?: string; + }) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + }>; + } + ).fetch; + + if (!globalFetch) { + throw new Error("Global fetch is unavailable in the runtime environment."); + } + + const response = await globalFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(`Network rejected transaction with status ${response.status}`); + } + + const parsedPayload = payload && typeof payload === "object" + ? (payload as Record) + : null; + + const hash = parsedPayload && typeof parsedPayload.hash === "string" + ? parsedPayload.hash + : "unknown"; + const ledger = parsedPayload && typeof parsedPayload.ledger === "number" + ? parsedPayload.ledger + : undefined; + + return { hash, ledger, raw: parsedPayload }; + } +} + +export class DevelopmentSeedSigner implements TransactionSigner { + private readonly seed: string; + + constructor(seed: string) { + this.seed = seed; + } + + async signTransactionXdr(unsignedXdr: string): Promise { + if (!this.seed) { + throw new Error("Missing development signing seed"); + } + + // Development-only placeholder signature marker. + return `${unsignedXdr}.devsig`; + } +} + +export class TransactionService { + private readonly config: TransactionServiceConfig; + private readonly networkClient: TransactionNetworkClient; + private readonly signer?: TransactionSigner; + + constructor( + config: TransactionServiceConfig, + networkClient: TransactionNetworkClient, + signer?: TransactionSigner, + ) { + this.config = config; + this.networkClient = networkClient; + this.signer = signer; + } + + async submitTransaction(input: SubmitTransactionInput): Promise { + this.assertUnsignedXdr(input.unsignedXdr); + + if (this.config.mode === "external_signer") { + return { + status: "awaiting_external_signature", + mode: "external_signer", + unsignedXdr: input.unsignedXdr, + signerUrl: this.config.externalSignerUrl, + }; + } + + if (!this.signer) { + throw new Error("Signer is required when TX_SIGNER_MODE=backend_sign"); + } + + try { + const signedXdr = await this.signer.signTransactionXdr(input.unsignedXdr); + return this.submitSignedXdr(signedXdr, "backend_sign"); + } catch (error) { + throw this.toSafeError("Failed to sign and submit transaction", error); + } + } + + async submitSignedXdr( + signedXdr: string, + mode: TransactionSignerMode = "external_signer", + ): Promise { + if (!signedXdr || !signedXdr.trim()) { + throw new Error("signedXdr is required"); + } + + try { + const result = await this.networkClient.submitSignedTransaction(signedXdr); + return { + status: "submitted", + mode, + hash: result.hash, + ledger: result.ledger, + signedXdr, + }; + } catch (error) { + throw this.toSafeError("Failed to submit transaction", error); + } + } + + private assertUnsignedXdr(unsignedXdr: string): void { + if (!unsignedXdr || !unsignedXdr.trim()) { + throw new Error("unsignedXdr is required"); + } + } + + private toSafeError(message: string, cause: unknown): Error { + const details = cause instanceof Error ? this.redact(cause.message) : "Unknown error"; + return new Error(`${message}: ${details}`); + } + + private redact(value: string): string { + let redacted = value; + + if (this.config.signingSeed) { + redacted = redacted.split(this.config.signingSeed).join("[REDACTED]"); + } + + return redacted; + } +}