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; + } +}