diff --git a/src/confidential.ts b/src/confidential.ts index 3faa464..90430ae 100644 --- a/src/confidential.ts +++ b/src/confidential.ts @@ -42,6 +42,18 @@ const STORE_NAME = "blindingFactors"; const DEFAULT_KEY_PREFIX = "stellarsplit:bf:"; const SESSION_KEY_NAME = "stellarsplit:encryption_key"; +export const SENSITIVE_FIELDS = ["secret", "privateKey", "seed", "mnemonic"] as const; + +export function maskSensitive(obj: Record): Record { + const clone: Record = { ...obj }; + for (const field of SENSITIVE_FIELDS) { + if (field in clone) { + clone[field] = "[REDACTED]"; + } + } + return clone; +} + // --------------------------------------------------------------------------- // Generator Point H (cached) // --------------------------------------------------------------------------- diff --git a/src/graphql.ts b/src/graphql.ts index 59f1ec1..b73a804 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,3 +1,38 @@ +export interface RecipientGraphQLResult { + address: string; + amount: string; +} + +export interface PaymentGraphQLResult { + payer: string; + amount: string; +} + +export interface InvoiceGraphQLResult { + id: string; + creator: string; + recipients: RecipientGraphQLResult[]; + token: string; + deadline: number; + funded: string; + status: string; + payments: PaymentGraphQLResult[]; + recurring?: boolean | null; +} + +export interface InvoiceQueryResponse { + invoice: InvoiceGraphQLResult | null; +} + +export interface InvoicesByCreatorQueryResponse { + invoicesByCreator: InvoiceGraphQLResult[]; +} + +export interface GraphQLQuery { + query: string; + variables: Record; +} + /** * generateGraphQLSchema — builds a GraphQL SDL string from SDK TypeScript interfaces. * @@ -37,3 +72,57 @@ type Query { } `.trim(); } + +export function buildInvoiceQuery(id: string): GraphQLQuery { + return { + query: ` +query Invoice($id: String!) { + invoice(id: $id) { + id + creator + recipients { + address + amount + } + token + deadline + funded + status + payments { + payer + amount + } + recurring + } +}`.trim(), + variables: { id }, + }; +} + +export function buildInvoicesByCreatorQuery( + address: string, +): GraphQLQuery { + return { + query: ` +query InvoicesByCreator($address: String!) { + invoicesByCreator(address: $address) { + id + creator + recipients { + address + amount + } + token + deadline + funded + status + payments { + payer + amount + } + recurring + } +}`.trim(), + variables: { address }, + }; +} diff --git a/src/resilientRpc.ts b/src/resilientRpc.ts index 81ad2c6..0d763b2 100644 --- a/src/resilientRpc.ts +++ b/src/resilientRpc.ts @@ -35,6 +35,8 @@ export interface RetryConfig { maxDelayMs: number; /** Whether to add random jitter to the delay. Default: true */ jitter: boolean; + /** Delay after which the same request is sent to a secondary client, if configured. */ + hedgeAfterMs?: number; } export const DEFAULT_RETRY_CONFIG: RetryConfig = { @@ -104,6 +106,7 @@ export interface ResilientRpcClientEvents { */ export class ResilientRpcClient extends EventEmitter { private readonly _inner: any; + private readonly _secondaryInner?: any; private readonly _retryConfig: RetryConfig; private readonly _circuitBreaker: CircuitBreaker; @@ -111,9 +114,11 @@ export class ResilientRpcClient extends EventEmitter { inner: any, retryConfig?: Partial, circuitBreakerConfig?: Partial, + secondaryInner?: any, ) { super(); this._inner = inner; + this._secondaryInner = secondaryInner; this._retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; this._circuitBreaker = new CircuitBreaker(circuitBreakerConfig); @@ -175,11 +180,26 @@ export class ResilientRpcClient extends EventEmitter { private _call(method: string, args: unknown[]): Promise { return this._executeWithResilience( - () => (this._inner[method] as (...a: unknown[]) => Promise)(...args), + () => this._invoke(method, args), method, ); } + private _invoke(method: string, args: unknown[]): Promise { + if (!this._secondaryInner || !this._retryConfig.hedgeAfterMs || this._retryConfig.hedgeAfterMs <= 0) { + return (this._inner[method] as (...a: unknown[]) => Promise)(...args); + } + + const primary = (this._inner[method] as (...a: unknown[]) => Promise)(...args); + const secondary = new Promise((resolve, reject) => { + setTimeout(() => { + void (this._secondaryInner[method] as (...a: unknown[]) => Promise)(...args).then(resolve, reject); + }, this._retryConfig.hedgeAfterMs); + }); + + return Promise.any([primary, secondary]); + } + private async _executeWithResilience( fn: () => Promise, method: string, diff --git a/src/splitRollbackCoordinator.ts b/src/splitRollbackCoordinator.ts index e5b8762..2ba2c6f 100644 --- a/src/splitRollbackCoordinator.ts +++ b/src/splitRollbackCoordinator.ts @@ -16,6 +16,20 @@ import { UnknownSplitError } from "./errors.js"; import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; import type { SplitLeg, SplitLegState, SplitRollbackCheckpoint } from "./types.js"; +export class RollbackTimeoutError extends Error { + constructor(splitId: string) { + super(`Rollback timed out for split ${splitId}`); + this.name = "RollbackTimeoutError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export interface RollbackTimeoutOptions { + timeoutMs: number; + execute: () => Promise; + cleanup?: () => Promise | void; +} + /** Event payloads emitted by {@link RollbackCoordinator}. */ export interface SplitRollbackEventMap { splitRollbackInitiated: { splitId: string; incomplete: SplitLeg[] }; @@ -108,6 +122,29 @@ export class RollbackCoordinator extends TypedEventEmitter { + const record = this.initiateRollback(splitId); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new RollbackTimeoutError(splitId)), options.timeoutMs); + }); + + try { + await Promise.race([options.execute(), timeoutPromise]); + return record; + } catch (error) { + if (!(error instanceof RollbackTimeoutError)) { + throw error; + } + + await this.cleanupTimedOutRollback(splitId, options.cleanup); + throw error; + } + } + private getCheckpoint(splitId: string): SplitRollbackCheckpoint { const checkpoint = this.checkpoints.get(splitId); if (!checkpoint) throw new UnknownSplitError(splitId); @@ -120,4 +157,22 @@ export class RollbackCoordinator extends TypedEventEmitter Promise | void, + ): Promise { + this.rollbackRecords.delete(splitId); + this.checkpoints.delete(splitId); + + if (!cleanup) { + return; + } + + try { + await cleanup(); + } catch (error) { + console.error("Rollback cleanup failed", error); + } + } } diff --git a/test/confidential.test.ts b/test/confidential.test.ts index b19b254..1b23a11 100644 --- a/test/confidential.test.ts +++ b/test/confidential.test.ts @@ -16,6 +16,8 @@ import "fake-indexeddb/auto"; import { generateCommitment, verifyCommitment, + maskSensitive, + SENSITIVE_FIELDS, storeBlindingFactor, loadBlindingFactor, deleteBlindingFactor, @@ -121,6 +123,30 @@ describe("Pedersen Commitment Generation", () => { }); }); +describe("maskSensitive", () => { + it("redacts known sensitive fields without mutating the original object", () => { + const input = { + secret: "s1", + privateKey: "p1", + seed: "seed words", + mnemonic: "twelve words", + safe: "visible", + }; + + const output = maskSensitive(input); + + expect(output).toEqual({ + secret: "[REDACTED]", + privateKey: "[REDACTED]", + seed: "[REDACTED]", + mnemonic: "[REDACTED]", + safe: "visible", + }); + expect(input.secret).toBe("s1"); + expect(SENSITIVE_FIELDS).toContain("privateKey"); + }); +}); + describe("Commitment Verification", () => { it("verifies correct value and blinding factor", () => { const amount = 500000000n; diff --git a/test/graphql.test.ts b/test/graphql.test.ts new file mode 100644 index 0000000..5fb86d3 --- /dev/null +++ b/test/graphql.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + buildInvoiceQuery, + buildInvoicesByCreatorQuery, + type InvoiceQueryResponse, + type InvoicesByCreatorQueryResponse, +} from "../src/graphql.js"; + +describe("graphql query builders", () => { + it("returns typed invoice query definitions", () => { + const query = buildInvoiceQuery("inv-1"); + const result: InvoiceQueryResponse = { invoice: null }; + + expect(query.variables.id).toBe("inv-1"); + expect(result.invoice).toBeNull(); + }); + + it("returns typed creator query definitions", () => { + const query = buildInvoicesByCreatorQuery("GCREATOR"); + const result: InvoicesByCreatorQueryResponse = { invoicesByCreator: [] }; + + expect(query.variables.address).toBe("GCREATOR"); + expect(result.invoicesByCreator).toEqual([]); + }); +}); diff --git a/test/resilience.test.ts b/test/resilience.test.ts index b0b8544..5958def 100644 --- a/test/resilience.test.ts +++ b/test/resilience.test.ts @@ -222,6 +222,32 @@ describe("ResilientRpcClient", () => { expect(result).toEqual({ accountId: expect.any(Function) }); }); + it("hedges to the secondary client after the configured delay", async () => { + const primary = createMockRpc(); + const secondary = createMockRpc(); + primary.getAccount = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve({ accountId: () => "GPRIMARY" }), 50)), + ); + secondary.getAccount = vi.fn( + () => new Promise((resolve) => setTimeout(() => resolve({ accountId: () => "GSECONDARY" }), 5)), + ); + + const resilient = new ResilientRpcClient( + primary, + { maxRetries: 1, baseDelayMs: 10, maxDelayMs: 100, jitter: false, hedgeAfterMs: 10 }, + undefined, + secondary, + ); + + const promise = resilient.getAccount("GABC"); + await vi.advanceTimersByTimeAsync(15); + const result = await promise; + + expect(primary.getAccount).toHaveBeenCalledWith("GABC"); + expect(secondary.getAccount).toHaveBeenCalledWith("GABC"); + expect(result).toEqual({ accountId: expect.any(Function) }); + }); + it("retries on transient errors with exponential backoff", async () => { const mock = createMockRpc(); const timeoutErr = new Error("network timeout"); diff --git a/test/splitRollbackCoordinator.test.ts b/test/splitRollbackCoordinator.test.ts new file mode 100644 index 0000000..aa855bd --- /dev/null +++ b/test/splitRollbackCoordinator.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { RollbackCoordinator, RollbackTimeoutError } from "../src/splitRollbackCoordinator.js"; + +describe("RollbackCoordinator", () => { + it("cleans up partial state when rollback execution times out", async () => { + vi.useFakeTimers(); + + const coordinator = new RollbackCoordinator(); + coordinator.begin("split-1", "invoice-1", [{ recipient: "GDEST", amount: 10n }]); + + const cleanup = vi.fn().mockRejectedValue(new Error("delete failed")); + const promise = coordinator.initiateRollbackWithTimeout("split-1", { + timeoutMs: 100, + execute: () => new Promise(() => undefined), + cleanup, + }); + + await vi.advanceTimersByTimeAsync(100); + + await expect(promise).rejects.toBeInstanceOf(RollbackTimeoutError); + expect(coordinator.getRollbackRecord("split-1")).toBeUndefined(); + expect(coordinator.getCheckpointFor("split-1")).toBeUndefined(); + + vi.useRealTimers(); + }); +});