From 1b6c80563c123dd5537ac841f9daae9ea491f446 Mon Sep 17 00:00:00 2001 From: oomokaro1 Date: Sat, 29 Aug 2026 20:07:54 +0100 Subject: [PATCH] feat: add contract execution policy, key rotation audit, contract audit trail, and witness validation - Add ContractCallExecutionPolicy with timeout, abort signal, and recovery strategies (#509) - Add key rotation audit tracking with history query and suspicious pattern detection (#511) - Add contract invocation audit trail with query, pagination, and JSON/CSV export (#516) - Add transaction witness validation for multi-signature verification (#510) --- src/account/index.ts | 11 + src/account/keyRotationAudit.ts | 62 ++++++ src/index.ts | 42 +++- src/soroban/contractAuditTrail.ts | 76 +++++++ src/soroban/contractCallExecutionPolicy.ts | 101 +++++++++ src/soroban/index.ts | 22 ++ src/tests/issues-509-511-516-510.test.ts | 235 +++++++++++++++++++++ src/transaction/index.ts | 8 + src/transaction/witnessValidation.ts | 108 ++++++++++ 9 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 src/account/keyRotationAudit.ts create mode 100644 src/soroban/contractAuditTrail.ts create mode 100644 src/soroban/contractCallExecutionPolicy.ts create mode 100644 src/tests/issues-509-511-516-510.test.ts create mode 100644 src/transaction/witnessValidation.ts diff --git a/src/account/index.ts b/src/account/index.ts index c62e60f..2f51485 100644 --- a/src/account/index.ts +++ b/src/account/index.ts @@ -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, diff --git a/src/account/keyRotationAudit.ts b/src/account/keyRotationAudit.ts new file mode 100644 index 0000000..1eab880 --- /dev/null +++ b/src/account/keyRotationAudit.ts @@ -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 { + 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; +} diff --git a/src/index.ts b/src/index.ts index f5235c0..e09e7b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -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 { @@ -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, diff --git a/src/soroban/contractAuditTrail.ts b/src/soroban/contractAuditTrail.ts new file mode 100644 index 0000000..c73092d --- /dev/null +++ b/src/soroban/contractAuditTrail.ts @@ -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; +} + +const auditEntries: ContractAuditEntry[] = []; + +export function recordContractInvocation(entry: Omit): 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)[h]; + return val === undefined || val === null ? "" : String(val); + }).join(","), + ); + + return [headers.join(","), ...rows].join("\n"); +} + +export function clearContractAuditLog(): void { + auditEntries.length = 0; +} diff --git a/src/soroban/contractCallExecutionPolicy.ts b/src/soroban/contractCallExecutionPolicy.ts new file mode 100644 index 0000000..f8a7063 --- /dev/null +++ b/src/soroban/contractCallExecutionPolicy.ts @@ -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>(); +const abortListeners = new Map void>(); + +export function withExecutionPolicy( + fn: () => Promise>, + policy?: ContractCallExecutionPolicy, +): Promise> { + 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>((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(policy: ContractCallExecutionPolicy | undefined, resolve: (r: SorokitResult) => 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(); +} diff --git a/src/soroban/index.ts b/src/soroban/index.ts index a1aa7d2..5a93aaf 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -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"; diff --git a/src/tests/issues-509-511-516-510.test.ts b/src/tests/issues-509-511-516-510.test.ts new file mode 100644 index 0000000..afe6957 --- /dev/null +++ b/src/tests/issues-509-511-516-510.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { withExecutionPolicy, cleanupAllExecutionPolicies } from "../soroban/contractCallExecutionPolicy"; +import { recordKeyRotation, getKeyRotationHistory, detectSuspiciousRotationPattern, clearKeyRotationAuditLog } from "../account/keyRotationAudit"; +import { recordContractInvocation, queryContractAuditLog, exportAuditLogAsJson, exportAuditLogAsCsv, clearContractAuditLog } from "../soroban/contractAuditTrail"; +import { verifyTransactionSignatures } from "../transaction/witnessValidation"; +import { ok, err, SorokitErrorCode } from "../shared/response"; + +describe("Contract Call Execution Policy (#509)", () => { + beforeEach(() => { + cleanupAllExecutionPolicies(); + }); + + afterEach(() => { + cleanupAllExecutionPolicies(); + }); + + it("returns result immediately when no policy provided", async () => { + const result = await withExecutionPolicy(async () => ok("success")); + expect(result.status).toBe("ok"); + expect(result.data).toBe("success"); + }); + + it("returns timeout error when operation takes too long", async () => { + const result = await withExecutionPolicy( + async () => { + await new Promise((r) => setTimeout(r, 200)); + return ok("late"); + }, + { timeoutMs: 50 }, + ); + expect(result.status).toBe("error"); + expect(result.error.code).toBe(SorokitErrorCode.OPERATION_TIMEOUT); + }); + + it("returns result when operation completes within timeout", async () => { + const result = await withExecutionPolicy( + async () => ok("fast"), + { timeoutMs: 1000 }, + ); + expect(result.status).toBe("ok"); + expect(result.data).toBe("fast"); + }); + + it("returns abort error when signal is aborted", async () => { + const controller = new AbortController(); + const result = withExecutionPolicy( + async () => { + await new Promise((r) => setTimeout(r, 500)); + return ok("late"); + }, + { abortSignal: controller.signal, timeoutMs: 5000 }, + ); + + controller.abort(); + const resolved = await result; + expect(resolved.status).toBe("error"); + expect(resolved.error.code).toBe(SorokitErrorCode.OPERATION_TIMEOUT); + }); + + it("returns abort error when signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await withExecutionPolicy( + async () => ok("never"), + { abortSignal: controller.signal }, + ); + expect(result.status).toBe("error"); + expect(result.error.code).toBe(SorokitErrorCode.OPERATION_TIMEOUT); + }); + + it("uses default 30s timeout when not specified", async () => { + const result = await withExecutionPolicy(async () => ok("ok")); + expect(result.status).toBe("ok"); + }); + + it("handles contract call errors gracefully", async () => { + const result = await withExecutionPolicy( + async () => { throw new Error("boom"); }, + { timeoutMs: 1000 }, + ); + expect(result.status).toBe("error"); + expect(result.error.code).toBe(SorokitErrorCode.CONTRACT_INVOKE_FAILED); + }); +}); + +describe("Key Rotation Audit (#511)", () => { + beforeEach(() => { + clearKeyRotationAuditLog(); + }); + + it("records a successful key rotation", () => { + const entry = recordKeyRotation({ + account: "GABC123", + previousSigner: "GOLD_KEY", + newSigner: "GNEW_KEY", + status: "success", + }); + expect(entry.account).toBe("GABC123"); + expect(entry.previousSigner).toBe("GOLD_KEY"); + expect(entry.newSigner).toBe("GNEW_KEY"); + expect(entry.status).toBe("success"); + expect(entry.timestamp).toBeGreaterThan(0); + }); + + it("records a failed key rotation", () => { + const entry = recordKeyRotation({ + account: "GABC123", + previousSigner: "GOLD_KEY", + newSigner: "GNEW_KEY", + status: "failed", + reason: "Invalid signature", + }); + expect(entry.status).toBe("failed"); + expect(entry.reason).toBe("Invalid signature"); + }); + + it("retrieves rotation history for an account", () => { + recordKeyRotation({ account: "GABC", previousSigner: "K1", newSigner: "K2", status: "success" }); + recordKeyRotation({ account: "GABC", previousSigner: "K2", newSigner: "K3", status: "success" }); + recordKeyRotation({ account: "GXYZ", previousSigner: "K4", newSigner: "K5", status: "success" }); + + const history = getKeyRotationHistory("GABC"); + expect(history).toHaveLength(2); + expect(history[0].newSigner).toBe("K2"); + expect(history[1].newSigner).toBe("K3"); + }); + + it("supports pagination", () => { + for (let i = 0; i < 5; i++) { + recordKeyRotation({ account: "GABC", previousSigner: `K${i}`, newSigner: `K${i + 1}`, status: "success" }); + } + + const page1 = getKeyRotationHistory("GABC", { limit: 2, offset: 0 }); + const page2 = getKeyRotationHistory("GABC", { limit: 2, offset: 2 }); + expect(page1).toHaveLength(2); + expect(page2).toHaveLength(2); + }); + + it("filters by status", () => { + recordKeyRotation({ account: "GABC", previousSigner: "K1", newSigner: "K2", status: "success" }); + recordKeyRotation({ account: "GABC", previousSigner: "K2", newSigner: "K3", status: "failed" }); + + const successes = getKeyRotationHistory("GABC", { status: "success" }); + const failures = getKeyRotationHistory("GABC", { status: "failed" }); + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + }); + + it("detects suspicious rotation pattern", () => { + const now = Date.now(); + for (let i = 0; i < 5; i++) { + recordKeyRotation({ account: "GABC", previousSigner: `K${i}`, newSigner: `K${i + 1}`, status: "success" }); + } + expect(detectSuspiciousRotationPattern("GABC", 60000, 3)).toBe(true); + expect(detectSuspiciousRotationPattern("GABC", 60000, 10)).toBe(false); + }); + + it("returns empty history for unknown account", () => { + const history = getKeyRotationHistory("GUNKNOWN"); + expect(history).toHaveLength(0); + }); +}); + +describe("Contract Audit Trail (#516)", () => { + beforeEach(() => { + clearContractAuditLog(); + }); + + it("records a contract invocation", () => { + const entry = recordContractInvocation({ + caller: "GCALLER", + contractId: "CABC123", + functionName: "transfer", + transactionId: "tx_hash_123", + status: "success", + durationMs: 150, + }); + expect(entry.caller).toBe("GCALLER"); + expect(entry.functionName).toBe("transfer"); + expect(entry.status).toBe("success"); + expect(entry.durationMs).toBe(150); + expect(entry.timestamp).toBeGreaterThan(0); + }); + + it("queries audit log with filters", () => { + recordContractInvocation({ caller: "G1", contractId: "C1", functionName: "transfer", status: "success" }); + recordContractInvocation({ caller: "G1", contractId: "C2", functionName: "swap", status: "failed" }); + recordContractInvocation({ caller: "G2", contractId: "C1", functionName: "transfer", status: "success" }); + + const filtered = queryContractAuditLog({ caller: "G1" }); + expect(filtered).toHaveLength(2); + }); + + it("supports pagination and ordering", () => { + for (let i = 0; i < 5; i++) { + recordContractInvocation({ caller: "G1", contractId: "C1", functionName: "fn", status: "success" }); + } + + const page = queryContractAuditLog({ limit: 2, offset: 2 }); + expect(page).toHaveLength(2); + }); + + it("exports as JSON", () => { + recordContractInvocation({ caller: "G1", contractId: "C1", functionName: "fn", status: "success" }); + const json = exportAuditLogAsJson(); + expect(json).toContain("G1"); + expect(JSON.parse(json)).toHaveLength(1); + }); + + it("exports as CSV", () => { + recordContractInvocation({ caller: "G1", contractId: "C1", functionName: "fn", status: "success" }); + const csv = exportAuditLogAsCsv(); + expect(csv).toContain("timestamp,caller,contractId"); + expect(csv).toContain("G1"); + }); + + it("returns empty CSV for empty log", () => { + expect(exportAuditLogAsCsv()).toBe(""); + }); + + it("filters by date range", () => { + const now = Date.now(); + recordContractInvocation({ caller: "G1", contractId: "C1", functionName: "fn", status: "success" }); + const recent = queryContractAuditLog({ since: now - 1000 }); + expect(recent).toHaveLength(1); + }); +}); + +describe("Transaction Witness Validation (#510)", () => { + it("returns error for invalid XDR", () => { + const result = verifyTransactionSignatures("invalid-xdr", "Test SDF Network ; September 2015"); + expect(result.status).toBe("error"); + expect(result.error.code).toBe(SorokitErrorCode.TX_BUILD_FAILED); + }); +}); diff --git a/src/transaction/index.ts b/src/transaction/index.ts index fbfcfce..4e85bc9 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -327,6 +327,14 @@ export type { MultiSigEnvelopeParams, MultiSigEnvelope, } from "./types"; + +export { + verifyTransactionSignatures, +} from "./witnessValidation"; +export type { + SignatureValidationResult, + WitnessValidationResult, +} from "./witnessValidation"; export { saveTransactionTemplate, loadTemplate, diff --git a/src/transaction/witnessValidation.ts b/src/transaction/witnessValidation.ts new file mode 100644 index 0000000..f29fa5f --- /dev/null +++ b/src/transaction/witnessValidation.ts @@ -0,0 +1,108 @@ +import { TransactionBuilder, Keypair } from "@stellar/stellar-sdk"; +import { ok, err, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +export interface SignatureValidationResult { + valid: boolean; + signerKey: string; + validSignature: boolean; + issues: string[]; +} + +export interface WitnessValidationResult { + valid: boolean; + signatureResults: SignatureValidationResult[]; + missingSigners: string[]; + duplicateSigners: string[]; + thresholdMet: boolean; + issues: string[]; +} + +export function verifyTransactionSignatures( + transactionXdr: string, + networkPassphrase: string, + requiredSigners?: string[], + threshold?: number, +): SorokitResult { + let transaction; + try { + transaction = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase); + } catch (cause) { + return err(SorokitErrorCode.TX_BUILD_FAILED, `Failed to parse transaction XDR: ${cause instanceof Error ? cause.message : String(cause)}`); + } + + const envelope = (transaction as any)._envelope || (transaction as any).tx; + const signatures = envelope?.signatures() || []; + const signatureResults: SignatureValidationResult[] = []; + const seenSigners = new Set(); + const duplicateSigners: string[] = []; + + for (const sig of signatures) { + const hint = sig.hint(); + const signature = sig.signature(); + + let signerKey = ""; + try { + signerKey = Buffer.from(hint).toString("hex").toUpperCase(); + } catch { + signatureResults.push({ + valid: false, + signerKey: "unknown", + validSignature: false, + issues: ["Could not decode signature hint"], + }); + continue; + } + + const sigBase64 = Buffer.from(signature).toString("base64"); + + let validSignature = false; + try { + const valid = Keypair.fromPublicKey(signerKey).verify(transaction.hash(), signature); + validSignature = valid; + } catch { + validSignature = false; + } + + if (seenSigners.has(signerKey)) { + duplicateSigners.push(signerKey); + } + seenSigners.add(signerKey); + + const issues: string[] = []; + if (!validSignature) issues.push("Signature verification failed"); + + signatureResults.push({ + valid: validSignature, + signerKey, + validSignature, + issues, + }); + } + + const issues: string[] = []; + let missingSigners: string[] = []; + + if (requiredSigners && requiredSigners.length > 0) { + missingSigners = requiredSigners.filter((s) => !seenSigners.has(s)); + if (missingSigners.length > 0) { + issues.push(`Missing required signers: ${missingSigners.join(", ")}`); + } + } + + if (duplicateSigners.length > 0) { + issues.push(`Duplicate signatures detected: ${duplicateSigners.join(", ")}`); + } + + const totalValidWeight = signatureResults.filter((r) => r.validSignature).length; + const thresholdMet = threshold ? totalValidWeight >= threshold : signatureResults.length > 0; + + return ok({ + valid: issues.length === 0, + signatureResults, + missingSigners, + duplicateSigners, + thresholdMet, + issues, + }); +}