Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/confidential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Record<string, unknown> {
const clone: Record<string, unknown> = { ...obj };
for (const field of SENSITIVE_FIELDS) {
if (field in clone) {
clone[field] = "[REDACTED]";
}
}
return clone;
}

// ---------------------------------------------------------------------------
// Generator Point H (cached)
// ---------------------------------------------------------------------------
Expand Down
89 changes: 89 additions & 0 deletions src/graphql.ts
Original file line number Diff line number Diff line change
@@ -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<TResponse> {
query: string;
variables: Record<string, string>;
}

/**
* generateGraphQLSchema — builds a GraphQL SDL string from SDK TypeScript interfaces.
*
Expand Down Expand Up @@ -37,3 +72,57 @@ type Query {
}
`.trim();
}

export function buildInvoiceQuery(id: string): GraphQLQuery<InvoiceQueryResponse> {
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<InvoicesByCreatorQueryResponse> {
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 },
};
}
22 changes: 21 additions & 1 deletion src/resilientRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -104,16 +106,19 @@ export interface ResilientRpcClientEvents {
*/
export class ResilientRpcClient extends EventEmitter {
private readonly _inner: any;
private readonly _secondaryInner?: any;
private readonly _retryConfig: RetryConfig;
private readonly _circuitBreaker: CircuitBreaker;

constructor(
inner: any,
retryConfig?: Partial<RetryConfig>,
circuitBreakerConfig?: Partial<CircuitBreakerConfig>,
secondaryInner?: any,
) {
super();
this._inner = inner;
this._secondaryInner = secondaryInner;
this._retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
this._circuitBreaker = new CircuitBreaker(circuitBreakerConfig);

Expand Down Expand Up @@ -175,11 +180,26 @@ export class ResilientRpcClient extends EventEmitter {

private _call(method: string, args: unknown[]): Promise<unknown> {
return this._executeWithResilience(
() => (this._inner[method] as (...a: unknown[]) => Promise<unknown>)(...args),
() => this._invoke(method, args),
method,
);
}

private _invoke(method: string, args: unknown[]): Promise<unknown> {
if (!this._secondaryInner || !this._retryConfig.hedgeAfterMs || this._retryConfig.hedgeAfterMs <= 0) {
return (this._inner[method] as (...a: unknown[]) => Promise<unknown>)(...args);
}

const primary = (this._inner[method] as (...a: unknown[]) => Promise<unknown>)(...args);
const secondary = new Promise<unknown>((resolve, reject) => {
setTimeout(() => {
void (this._secondaryInner[method] as (...a: unknown[]) => Promise<unknown>)(...args).then(resolve, reject);
}, this._retryConfig.hedgeAfterMs);
});

return Promise.any([primary, secondary]);
}

private async _executeWithResilience<T>(
fn: () => Promise<T>,
method: string,
Expand Down
55 changes: 55 additions & 0 deletions src/splitRollbackCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
cleanup?: () => Promise<void> | void;
}

/** Event payloads emitted by {@link RollbackCoordinator}. */
export interface SplitRollbackEventMap {
splitRollbackInitiated: { splitId: string; incomplete: SplitLeg[] };
Expand Down Expand Up @@ -108,6 +122,29 @@ export class RollbackCoordinator extends TypedEventEmitter<SplitRollbackEventMap
return this.checkpoints.get(splitId);
}

async initiateRollbackWithTimeout(
splitId: string,
options: RollbackTimeoutOptions,
): Promise<SplitRollbackRecord> {
const record = this.initiateRollback(splitId);

const timeoutPromise = new Promise<never>((_, 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);
Expand All @@ -120,4 +157,22 @@ export class RollbackCoordinator extends TypedEventEmitter<SplitRollbackEventMap
if (!leg) throw new UnknownSplitError(`${splitId}[${legIndex}]`);
leg.state = state;
}

private async cleanupTimedOutRollback(
splitId: string,
cleanup?: () => Promise<void> | void,
): Promise<void> {
this.rollbackRecords.delete(splitId);
this.checkpoints.delete(splitId);

if (!cleanup) {
return;
}

try {
await cleanup();
} catch (error) {
console.error("Rollback cleanup failed", error);
}
}
}
26 changes: 26 additions & 0 deletions test/confidential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import "fake-indexeddb/auto";
import {
generateCommitment,
verifyCommitment,
maskSensitive,
SENSITIVE_FIELDS,
storeBlindingFactor,
loadBlindingFactor,
deleteBlindingFactor,
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions test/graphql.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
26 changes: 26 additions & 0 deletions test/resilience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
26 changes: 26 additions & 0 deletions test/splitRollbackCoordinator.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(() => 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();
});
});