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
11 changes: 11 additions & 0 deletions src/account/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ export {
recoverAccountKeys,
isValidStellarPublicKey,
} from "./keyRotation";
export {
recordKeyRotation,
getKeyRotationHistory,
detectSuspiciousRotationPattern,
clearKeyRotationAuditLog,
} from "./keyRotationAudit";
export type {
KeyRotationAuditEntry,
KeyRotationStatus,
GetKeyRotationHistoryOptions,
} from "./keyRotationAudit";
export {
getAccountActivitySummary,
clearAccountActivitySummaryCache,
Expand Down
62 changes: 62 additions & 0 deletions src/account/keyRotationAudit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type KeyRotationStatus = "success" | "failed";

export interface KeyRotationAuditEntry {
timestamp: number;
account: string;
operator?: string;
previousSigner: string;
newSigner: string;
transactionId?: string;
status: KeyRotationStatus;
reason?: string;
}

const auditLog: KeyRotationAuditEntry[] = [];

export function recordKeyRotation(entry: Omit<KeyRotationAuditEntry, "timestamp">): KeyRotationAuditEntry {
const record: KeyRotationAuditEntry = {
...entry,
timestamp: Date.now(),
};
auditLog.push(record);
return record;
}

export interface GetKeyRotationHistoryOptions {
limit?: number;
offset?: number;
status?: KeyRotationStatus;
}

export function getKeyRotationHistory(
account: string,
options?: GetKeyRotationHistoryOptions,
): KeyRotationAuditEntry[] {
let entries = auditLog.filter((e) => e.account === account);

if (options?.status) {
entries = entries.filter((e) => e.status === options.status);
}

entries.sort((a, b) => a.timestamp - b.timestamp);

const offset = options?.offset ?? 0;
const limit = options?.limit ?? entries.length;
return entries.slice(offset, offset + limit);
}

export function detectSuspiciousRotationPattern(
account: string,
windowMs: number = 3600_000,
threshold: number = 3,
): boolean {
const now = Date.now();
const recent = auditLog.filter(
(e) => e.account === account && e.timestamp >= now - windowMs,
);
return recent.length >= threshold;
}

export function clearKeyRotationAuditLog(): void {
auditLog.length = 0;
}
42 changes: 41 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ export type {
RecoverAccountKeysParams,
RecoveryReplacementSigner,
} from "./account/keyRotation";
export {
recordKeyRotation,
getKeyRotationHistory,
detectSuspiciousRotationPattern,
clearKeyRotationAuditLog,
} from "./account/keyRotationAudit";
export type {
KeyRotationAuditEntry,
KeyRotationStatus,
GetKeyRotationHistoryOptions,
} from "./account/keyRotationAudit";
export {
getAccountActivitySummary,
clearAccountActivitySummaryCache,
Expand Down Expand Up @@ -261,6 +272,13 @@ export type {
MultiSigEnvelopeParams,
MultiSigEnvelope,
} from "./transaction/types";
export {
verifyTransactionSignatures,
} from "./transaction/witnessValidation";
export type {
SignatureValidationResult,
WitnessValidationResult,
} from "./transaction/witnessValidation";

// ─── Transaction types ────────────────────────────────────────────────────────
export type {
Expand Down Expand Up @@ -557,8 +575,30 @@ export type {
BuilderStateListener,
BuilderStateUnsubscribe,
} from "./soroban";
export { invokeContract } from "./soroban/invokeContract";
export {
invokeContract,
} from "./soroban/invokeContract";
export type { InvokeContractOptions } from "./soroban/invokeContract";
export {
withExecutionPolicy,
cleanupAllExecutionPolicies,
} from "./soroban/contractCallExecutionPolicy";
export type {
ContractCallExecutionPolicy,
TimeoutRecoveryStrategy,
} from "./soroban/contractCallExecutionPolicy";
export {
recordContractInvocation,
queryContractAuditLog,
exportAuditLogAsJson,
exportAuditLogAsCsv,
clearContractAuditLog,
} from "./soroban/contractAuditTrail";
export type {
ContractAuditEntry,
ContractAuditFilter,
ContractAuditStatus,
} from "./soroban/contractAuditTrail";
export { buildContractDeploy } from "./soroban/deployContract";
export {
validateDeployConfig,
Expand Down
76 changes: 76 additions & 0 deletions src/soroban/contractAuditTrail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export type ContractAuditStatus = "success" | "failed";

export interface ContractAuditEntry {
timestamp: number;
caller: string;
contractId: string;
functionName: string;
transactionId?: string;
durationMs?: number;
status: ContractAuditStatus;
sanitizedArgs?: Record<string, unknown>;
}

const auditEntries: ContractAuditEntry[] = [];

export function recordContractInvocation(entry: Omit<ContractAuditEntry, "timestamp">): ContractAuditEntry {
const record: ContractAuditEntry = {
...entry,
timestamp: Date.now(),
};
auditEntries.push(record);
return record;
}

export interface ContractAuditFilter {
caller?: string;
contractId?: string;
functionName?: string;
status?: ContractAuditStatus;
since?: number;
until?: number;
limit?: number;
offset?: number;
}

export function queryContractAuditLog(filters?: ContractAuditFilter): ContractAuditEntry[] {
let results = [...auditEntries];

if (filters) {
if (filters.caller) results = results.filter((e) => e.caller === filters.caller);
if (filters.contractId) results = results.filter((e) => e.contractId === filters.contractId);
if (filters.functionName) results = results.filter((e) => e.functionName === filters.functionName);
if (filters.status) results = results.filter((e) => e.status === filters.status);
if (filters.since) results = results.filter((e) => e.timestamp >= filters.since!);
if (filters.until) results = results.filter((e) => e.timestamp <= filters.until!);
}

results.sort((a, b) => a.timestamp - b.timestamp);

const offset = filters?.offset ?? 0;
const limit = filters?.limit ?? results.length;
return results.slice(offset, offset + limit);
}

export function exportAuditLogAsJson(entries?: ContractAuditEntry[]): string {
return JSON.stringify(entries ?? auditEntries, null, 2);
}

export function exportAuditLogAsCsv(entries?: ContractAuditEntry[]): string {
const data = entries ?? auditEntries;
if (data.length === 0) return "";

const headers = ["timestamp", "caller", "contractId", "functionName", "transactionId", "durationMs", "status"];
const rows = data.map((e) =>
headers.map((h) => {
const val = (e as unknown as Record<string, unknown>)[h];
return val === undefined || val === null ? "" : String(val);
}).join(","),
);

return [headers.join(","), ...rows].join("\n");
}

export function clearContractAuditLog(): void {
auditEntries.length = 0;
}
101 changes: 101 additions & 0 deletions src/soroban/contractCallExecutionPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { err, ok, SorokitErrorCode } from "../shared/response";
import type { SorokitResult } from "../shared/response";

export type TimeoutRecoveryStrategy = "fail" | "retry" | "cache" | "fallback";

export interface ContractCallExecutionPolicy {
timeoutMs?: number;
abortSignal?: AbortSignal;
recoveryStrategy?: TimeoutRecoveryStrategy;
fallbackRpcUrl?: string;
cacheKey?: string;
}

const DEFAULT_TIMEOUT_MS = 30_000;

const pendingTimers = new Map<object, ReturnType<typeof setTimeout>>();
const abortListeners = new Map<object, () => void>();

export function withExecutionPolicy<T>(
fn: () => Promise<SorokitResult<T>>,
policy?: ContractCallExecutionPolicy,
): Promise<SorokitResult<T>> {
if (!policy) return fn();

const timeoutMs = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
const key = {};

if (policy.abortSignal) {
if (policy.abortSignal.aborted) {
return Promise.resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, "Operation aborted before start"));
}
const onAbort = () => controller.abort();
policy.abortSignal.addEventListener("abort", onAbort, { once: true });
abortListeners.set(key, onAbort);
}

return new Promise<SorokitResult<T>>((resolve) => {
const timer = setTimeout(() => {
cleanup();
handleTimeout(policy, resolve);
}, timeoutMs);

pendingTimers.set(key, timer);

controller.signal.addEventListener("abort", () => {
cleanup();
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, "Contract call cancelled"));
});

function cleanup() {
const t = pendingTimers.get(key);
if (t) clearTimeout(t);
pendingTimers.delete(key);
const listener = abortListeners.get(key);
if (listener && policy?.abortSignal) {
policy.abortSignal.removeEventListener("abort", listener);
}
abortListeners.delete(key);
}

fn()
.then((result) => {
cleanup();
resolve(result);
})
.catch((cause) => {
cleanup();
resolve(err(SorokitErrorCode.CONTRACT_INVOKE_FAILED, `Contract call failed: ${cause instanceof Error ? cause.message : String(cause)}`, cause));
});
});
}

function handleTimeout<T>(policy: ContractCallExecutionPolicy | undefined, resolve: (r: SorokitResult<T>) => void) {
const strategy = policy?.recoveryStrategy ?? "fail";
const timeoutMs = policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
switch (strategy) {
case "fail":
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, `Contract call timed out after ${timeoutMs}ms`));
break;
case "retry":
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, `Contract call timed out (retry not implemented in simple version)`));
break;
case "cache":
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, `Contract call timed out (cache not available)`));
break;
case "fallback":
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, `Contract call timed out (fallback RPC not configured)`));
break;
default:
resolve(err(SorokitErrorCode.OPERATION_TIMEOUT, `Contract call timed out`));
}
}

export function cleanupAllExecutionPolicies() {
for (const [, timer] of pendingTimers) {
clearTimeout(timer);
}
pendingTimers.clear();
abortListeners.clear();
}
22 changes: 22 additions & 0 deletions src/soroban/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,25 @@ export type {
ContractSigningRequest,
SigningRequestState,
} from "./multiSigExecution";

export {
withExecutionPolicy,
cleanupAllExecutionPolicies,
} from "./contractCallExecutionPolicy";
export type {
ContractCallExecutionPolicy,
TimeoutRecoveryStrategy,
} from "./contractCallExecutionPolicy";

export {
recordContractInvocation,
queryContractAuditLog,
exportAuditLogAsJson,
exportAuditLogAsCsv,
clearContractAuditLog,
} from "./contractAuditTrail";
export type {
ContractAuditEntry,
ContractAuditFilter,
ContractAuditStatus,
} from "./contractAuditTrail";
Loading