diff --git a/src/index.ts b/src/index.ts index 473272e..f5235c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -875,6 +875,61 @@ export type { CongestionLevel, CongestionSnapshot, } from "./network/congestionMonitor"; +export { SpendingPolicyEngine, createSpendingPolicyEngine } from "./transaction/spendingPolicy"; +export type { + SpendingLimitPeriod, + SpendingLimit, + DestinationRestriction, + ApprovalThreshold, + SpendingPolicyConfig, + SpendingRequest, + SpendingRecordStatus, + SpendingRecord, + PolicyViolationCode, + PolicyViolation, + SpendingDecision, + SpendingEvaluation, + SpendingUsage, +} from "./transaction/spendingPolicy"; +export { + ContractStateHistory, + createContractStateHistory, + fingerprintState, +} from "./soroban/contractStateHistory"; +export type { + ContractStateSnapshotRecord, + CaptureSnapshotInput, + ContractStatePin, + StateEntryChangeKind, + StateEntryChange, + ContractStateComparison, + SnapshotIntegrityReport, + SnapshotQuery, +} from "./soroban/contractStateHistory"; +export { + MultiSigContractExecution, + createMultiSigContractExecution, +} from "./soroban/multiSigExecution"; +export type { + ContractExecutionSigner, + CreateSigningRequestInput, + CollectedSignature, + SigningRequestStatus, + ContractSigningRequest, + SigningRequestState, +} from "./soroban/multiSigExecution"; +export { auditWalletSecurity, isHighRiskConnection } from "./wallet/securityAudit"; +export type { + RiskSeverity, + RiskConfidence, + RiskFactor, + WalletVulnerability, + VulnerabilitySource, + WalletConnectionContext, + WalletSecurityAuditOptions, + RiskLevel, + WalletSecurityReport, +} from "./wallet/securityAudit"; // ─── Historical fee forecasting (#523) ──────────────────────────────────────── export { diff --git a/src/soroban/contractStateHistory.ts b/src/soroban/contractStateHistory.ts new file mode 100644 index 0000000..27a66ad --- /dev/null +++ b/src/soroban/contractStateHistory.ts @@ -0,0 +1,366 @@ +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +// ─── Types ─── + +/** + * A recorded contract state snapshot. + * + * A snapshot is SDK-managed: it holds the state that was supplied to + * {@link ContractStateHistory.captureSnapshot} at the moment of capture. It is + * not a claim that this state can be reconstructed from the network later — see + * {@link ContractStateHistory} for the distinction. + */ +export interface ContractStateSnapshotRecord { + /** Stable identifier, unique within the history. */ + id: string; + contractId: string; + /** Ledger sequence the state was read at, used as the version identifier. */ + ledger: number; + /** Epoch milliseconds at which the snapshot was captured. */ + timestamp: number; + /** Captured state, stored as supplied. */ + state: Readonly>; + /** Deterministic fingerprint of `state` — see {@link fingerprintState}. */ + fingerprint: string; + /** Optional human-readable label. */ + label?: string; +} + +export interface CaptureSnapshotInput { + contractId: string; + ledger: number; + state: Record; + /** Epoch milliseconds. Defaults to `Date.now()`. */ + timestamp?: number; + label?: string; +} + +/** A pin marking one snapshot as the active version for a contract. */ +export interface ContractStatePin { + contractId: string; + snapshotId: string; + ledger: number; + pinnedAt: number; +} + +export type StateEntryChangeKind = "added" | "removed" | "changed"; + +export interface StateEntryChange { + key: string; + kind: StateEntryChangeKind; + /** Value in the earlier snapshot. Absent when the entry was added. */ + from?: unknown; + /** Value in the later snapshot. Absent when the entry was removed. */ + to?: unknown; +} + +export interface ContractStateComparison { + contractId: string; + fromSnapshotId: string; + toSnapshotId: string; + fromLedger: number; + toLedger: number; + /** True when both snapshots carry the same fingerprint. */ + identical: boolean; + changes: readonly StateEntryChange[]; +} + +export interface SnapshotIntegrityReport { + snapshotId: string; + valid: boolean; + expectedFingerprint: string; + actualFingerprint: string; +} + +export interface SnapshotQuery { + contractId?: string; + /** Only snapshots at or after this ledger. */ + fromLedger?: number; + /** Only snapshots at or before this ledger. */ + toLedger?: number; +} + +// ─── Fingerprinting ─── + +/** + * Canonical JSON encoding: object keys are emitted in sorted order at every + * depth so that two structurally equal states always encode identically, + * regardless of the insertion order of their keys. + */ +function canonicalize(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "bigint") return `"${value.toString()}"`; + if (typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`); + return `{${entries.join(",")}}`; +} + +/** + * Deterministic fingerprint of a contract state object. + * + * This is a non-cryptographic FNV-1a digest over the canonical encoding. It + * detects accidental drift and lets two snapshots be compared cheaply; it is + * not collision-resistant and must not be used as a security boundary. + */ +export function fingerprintState(state: Record): string { + const encoded = canonicalize(state); + let hash = 0x811c9dc5n; + const PRIME = 0x01000193n; + const MASK = 0xffffffffn; + + for (let index = 0; index < encoded.length; index += 1) { + hash = ((hash ^ BigInt(encoded.charCodeAt(index))) * PRIME) & MASK; + } + return hash.toString(16).padStart(8, "0"); +} + +// ─── History ─── + +/** + * Registry of SDK-managed contract state snapshots. + * + * Scope: this class stores state that was captured through it and can pin, + * query and compare those records. It cannot reconstruct arbitrary historical + * ledger state — Stellar RPC nodes retain contract data for a limited retention + * window, and any ledger outside that window (or never captured here) is simply + * unavailable. Queries for such state report a clear error rather than + * returning an empty or synthesized result. + */ +export class ContractStateHistory { + private readonly snapshots = new Map(); + private readonly pins = new Map(); + private sequence = 0; + + /** + * Record a snapshot of contract state at a given ledger. + * + * The state is fingerprinted at capture time. Capturing the same contract at + * the same ledger twice is allowed and produces two independent records. + */ + captureSnapshot(input: CaptureSnapshotInput): SorokitResult { + if (!input.contractId.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "captureSnapshot: contractId is required."); + } + if (!Number.isInteger(input.ledger) || input.ledger < 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `captureSnapshot: ledger must be a non-negative integer (got ${input.ledger}).`, + ); + } + if (input.state === null || typeof input.state !== "object") { + return err(SorokitErrorCode.INVALID_CONFIG, "captureSnapshot: state must be an object."); + } + + this.sequence += 1; + const state = Object.freeze({ ...input.state }); + const record: ContractStateSnapshotRecord = { + id: `${input.contractId}:${input.ledger}:${this.sequence}`, + contractId: input.contractId, + ledger: input.ledger, + timestamp: input.timestamp ?? Date.now(), + state, + fingerprint: fingerprintState(state), + ...(input.label !== undefined ? { label: input.label } : {}), + }; + + this.snapshots.set(record.id, record); + return ok(record); + } + + /** + * Pin a contract to a specific captured version. + * + * `version` is a ledger sequence. The most recently captured snapshot at that + * ledger becomes the pinned state. Pinning a ledger that was never captured + * fails, rather than silently pinning the nearest one. + */ + pinContractState(contractId: string, version: number): SorokitResult { + const candidates = [...this.snapshots.values()] + .filter((snapshot) => snapshot.contractId === contractId && snapshot.ledger === version) + .sort((a, b) => a.timestamp - b.timestamp); + + const target = candidates[candidates.length - 1]; + if (!target) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `pinContractState: no snapshot captured for contract ${contractId} at ledger ${version}. ` + + "Historical state outside the captured set is not retrievable from this SDK — capture it first.", + ); + } + + const pin: ContractStatePin = { + contractId, + snapshotId: target.id, + ledger: target.ledger, + pinnedAt: Date.now(), + }; + this.pins.set(contractId, pin); + return ok(pin); + } + + /** Return the active pin for a contract, if one is set. */ + getPin(contractId: string): ContractStatePin | undefined { + return this.pins.get(contractId); + } + + /** Resolve the snapshot a contract is pinned to. */ + getPinnedState(contractId: string): SorokitResult { + const pin = this.pins.get(contractId); + if (!pin) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getPinnedState: contract ${contractId} is not pinned to any version.`, + ); + } + const snapshot = this.snapshots.get(pin.snapshotId); + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getPinnedState: pinned snapshot ${pin.snapshotId} is no longer available.`, + ); + } + return ok(snapshot); + } + + /** Remove a contract's pin. Returns true when one was removed. */ + unpinContractState(contractId: string): boolean { + return this.pins.delete(contractId); + } + + /** Look up one snapshot by id. */ + getSnapshot(snapshotId: string): SorokitResult { + const snapshot = this.snapshots.get(snapshotId); + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getSnapshot: snapshot ${snapshotId} was not found in this history.`, + ); + } + return ok(snapshot); + } + + /** + * Query captured snapshots, newest ledger first. + * + * Returns only what this history holds. An empty result means nothing was + * captured for that range — not that the contract had no state. + */ + querySnapshots(query: SnapshotQuery = {}): ContractStateSnapshotRecord[] { + return [...this.snapshots.values()] + .filter((snapshot) => { + if (query.contractId !== undefined && snapshot.contractId !== query.contractId) return false; + if (query.fromLedger !== undefined && snapshot.ledger < query.fromLedger) return false; + if (query.toLedger !== undefined && snapshot.ledger > query.toLedger) return false; + return true; + }) + .sort((a, b) => b.ledger - a.ledger || b.timestamp - a.timestamp); + } + + /** + * Return the snapshot for a contract at an exact ledger. + * + * Reports an explicit error when that ledger was never captured, so callers + * can distinguish "unavailable" from "empty state". + */ + getSnapshotAtLedger(contractId: string, ledger: number): SorokitResult { + const matches = this.querySnapshots({ contractId, fromLedger: ledger, toLedger: ledger }); + const snapshot = matches[0]; + if (!snapshot) { + return err( + SorokitErrorCode.CONTRACT_READ_FAILED, + `getSnapshotAtLedger: no snapshot captured for contract ${contractId} at ledger ${ledger}. ` + + "This SDK reports only snapshots it captured; it does not reconstruct historical ledger state.", + ); + } + return ok(snapshot); + } + + /** + * Recompute a snapshot's fingerprint and compare it to the stored value. + * + * A mismatch means the stored state was mutated after capture. + */ + verifySnapshotIntegrity(snapshotId: string): SorokitResult { + const found = this.getSnapshot(snapshotId); + if (found.status === "error") return found; + + const snapshot = found.data; + const actual = fingerprintState(snapshot.state as Record); + return ok({ + snapshotId, + valid: actual === snapshot.fingerprint, + expectedFingerprint: snapshot.fingerprint, + actualFingerprint: actual, + }); + } + + /** + * Compare two captured snapshots key by key. + * + * Both snapshots must belong to the same contract; comparing across contracts + * is rejected as a programming error rather than producing a diff of + * unrelated state. + */ + compareSnapshots(fromSnapshotId: string, toSnapshotId: string): SorokitResult { + const fromResult = this.getSnapshot(fromSnapshotId); + if (fromResult.status === "error") return fromResult; + const toResult = this.getSnapshot(toSnapshotId); + if (toResult.status === "error") return toResult; + + const from = fromResult.data; + const to = toResult.data; + + if (from.contractId !== to.contractId) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `compareSnapshots: snapshots belong to different contracts (${from.contractId} vs ${to.contractId}).`, + ); + } + + const changes: StateEntryChange[] = []; + const keys = new Set([...Object.keys(from.state), ...Object.keys(to.state)]); + + for (const key of [...keys].sort()) { + const hadKey = Object.prototype.hasOwnProperty.call(from.state, key); + const hasKey = Object.prototype.hasOwnProperty.call(to.state, key); + const before = from.state[key]; + const after = to.state[key]; + + if (!hadKey && hasKey) { + changes.push({ key, kind: "added", to: after }); + } else if (hadKey && !hasKey) { + changes.push({ key, kind: "removed", from: before }); + } else if (canonicalize(before) !== canonicalize(after)) { + changes.push({ key, kind: "changed", from: before, to: after }); + } + } + + return ok({ + contractId: from.contractId, + fromSnapshotId, + toSnapshotId, + fromLedger: from.ledger, + toLedger: to.ledger, + identical: from.fingerprint === to.fingerprint, + changes, + }); + } + + /** Discard all snapshots and pins. */ + clear(): void { + this.snapshots.clear(); + this.pins.clear(); + this.sequence = 0; + } +} + +/** Construct a {@link ContractStateHistory}. */ +export function createContractStateHistory(): ContractStateHistory { + return new ContractStateHistory(); +} diff --git a/src/soroban/index.ts b/src/soroban/index.ts index 0f0f763..a1aa7d2 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -499,3 +499,32 @@ export type { StorageRecommendation, StorageAnalysisReport, } from "./storageAnalysis"; + +export { + ContractStateHistory, + createContractStateHistory, + fingerprintState, +} from "./contractStateHistory"; +export type { + ContractStateSnapshotRecord, + CaptureSnapshotInput, + ContractStatePin, + StateEntryChangeKind, + StateEntryChange, + ContractStateComparison, + SnapshotIntegrityReport, + SnapshotQuery, +} from "./contractStateHistory"; + +export { + MultiSigContractExecution, + createMultiSigContractExecution, +} from "./multiSigExecution"; +export type { + ContractExecutionSigner, + CreateSigningRequestInput, + CollectedSignature, + SigningRequestStatus, + ContractSigningRequest, + SigningRequestState, +} from "./multiSigExecution"; diff --git a/src/soroban/multiSigExecution.ts b/src/soroban/multiSigExecution.ts new file mode 100644 index 0000000..c9c15e5 --- /dev/null +++ b/src/soroban/multiSigExecution.ts @@ -0,0 +1,425 @@ +import { Keypair, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; +import { toMessage } from "../shared"; + +// ─── Types ─── + +/** A signer permitted to authorize a contract execution request. */ +export interface ContractExecutionSigner { + publicKey: string; + /** Weight this signer contributes toward the threshold. Defaults to 1. */ + weight?: number; +} + +export interface CreateSigningRequestInput { + /** Prepared, unsigned contract invocation XDR. */ + transactionXdr: string; + networkPassphrase: string; + signers: readonly ContractExecutionSigner[]; + /** Total weight required before execution is permitted. */ + threshold: number; + /** Epoch milliseconds after which the request can no longer be executed. */ + expiresAt?: number; + /** Epoch milliseconds treated as creation time. Defaults to `Date.now()`. */ + now?: number; +} + +/** A signature contributed by one signer. */ +export interface CollectedSignature { + publicKey: string; + /** Base64-encoded Ed25519 signature over the canonical payload. */ + signature: string; + weight: number; + collectedAt: number; +} + +export type SigningRequestStatus = "collecting" | "ready" | "expired" | "executed"; + +/** + * A pending N-of-M contract execution. + * + * `payloadHash` is the canonical signing payload: the transaction hash for the + * given network passphrase. Every signature is verified against this exact + * value, so the request cannot be replayed against a different network or a + * different transaction body. + */ +export interface ContractSigningRequest { + id: string; + transactionXdr: string; + networkPassphrase: string; + /** Hex-encoded transaction hash that signers must sign. */ + payloadHash: string; + signers: readonly Required[]; + threshold: number; + signatures: readonly CollectedSignature[]; + collectedWeight: number; + status: SigningRequestStatus; + createdAt: number; + expiresAt?: number; +} + +export interface SigningRequestState { + id: string; + status: SigningRequestStatus; + collectedWeight: number; + threshold: number; + /** Weight still needed. Zero once the threshold is satisfied. */ + remainingWeight: number; + thresholdMet: boolean; + signedBy: readonly string[]; + pendingSigners: readonly string[]; + expiresAt?: number; +} + +// ─── Helpers ─── + +function normalizeSigners( + signers: readonly ContractExecutionSigner[], +): Required[] { + return signers.map((signer) => ({ publicKey: signer.publicKey, weight: signer.weight ?? 1 })); +} + +/** + * Compute the canonical payload signers must sign: the transaction hash bound + * to the network passphrase. + */ +function computePayloadHash( + transactionXdr: string, + networkPassphrase: string, +): SorokitResult { + try { + const transaction = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase); + return ok(transaction.hash()); + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `Signing request payload could not be derived — ${toMessage(cause)}`, + cause, + ); + } +} + +function isExpired(request: ContractSigningRequest, now: number): boolean { + return request.expiresAt !== undefined && now >= request.expiresAt; +} + +function recompute(request: ContractSigningRequest, now: number): ContractSigningRequest { + if (request.status === "executed") return request; + if (isExpired(request, now)) return { ...request, status: "expired" }; + return { + ...request, + status: request.collectedWeight >= request.threshold ? "ready" : "collecting", + }; +} + +// ─── Workflow ─── + +/** + * Coordinates N-of-M authorization for a Soroban contract invocation. + * + * The workflow separates preparation, signature collection, validation and + * submission. It never signs: callers sign the request's `payloadHash` with + * their own wallet or keypair and submit the resulting signature here. + * + * Signatures are verified as Ed25519 signatures over the transaction hash — the + * same payload the Stellar protocol itself signs — so no separate signing + * format is introduced. + */ +export class MultiSigContractExecution { + private readonly requests = new Map(); + private sequence = 0; + + /** + * Create a signing request for a prepared contract invocation. + * + * Validates that the XDR parses for the given network, that signers are + * unique and positively weighted, and that the threshold is reachable. + */ + createSigningRequest(input: CreateSigningRequestInput): SorokitResult { + const signers = normalizeSigners(input.signers); + + if (signers.length === 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "createSigningRequest: at least one signer is required.", + ); + } + + const seen = new Set(); + for (const signer of signers) { + if (seen.has(signer.publicKey)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: signer ${signer.publicKey} is listed more than once.`, + ); + } + seen.add(signer.publicKey); + + if (signer.weight < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: signer ${signer.publicKey} has invalid weight ${signer.weight} — must be >= 1.`, + ); + } + try { + Keypair.fromPublicKey(signer.publicKey); + } catch { + return err( + SorokitErrorCode.INVALID_ADDRESS, + `createSigningRequest: ${signer.publicKey} is not a valid Stellar public key.`, + ); + } + } + + if (!Number.isFinite(input.threshold) || input.threshold < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: threshold must be >= 1 (got ${input.threshold}).`, + ); + } + + const totalWeight = signers.reduce((sum, signer) => sum + signer.weight, 0); + if (input.threshold > totalWeight) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `createSigningRequest: threshold (${input.threshold}) exceeds total signer weight (${totalWeight}) and can never be met.`, + ); + } + + const payload = computePayloadHash(input.transactionXdr, input.networkPassphrase); + if (payload.status === "error") return payload; + + const now = input.now ?? Date.now(); + if (input.expiresAt !== undefined && input.expiresAt <= now) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "createSigningRequest: expiresAt must be in the future.", + ); + } + + this.sequence += 1; + const request: ContractSigningRequest = { + id: `csr-${this.sequence}-${payload.data.toString("hex").slice(0, 8)}`, + transactionXdr: input.transactionXdr, + networkPassphrase: input.networkPassphrase, + payloadHash: payload.data.toString("hex"), + signers, + threshold: input.threshold, + signatures: [], + collectedWeight: 0, + status: "collecting", + createdAt: now, + ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}), + }; + + this.requests.set(request.id, request); + return ok(request); + } + + /** + * Add a signature to a request. + * + * The signature must be a valid Ed25519 signature by `publicKey` over the + * request's `payloadHash`. Invalid signatures are rejected and contribute no + * weight. A signer that has already contributed is rejected, so one signer + * cannot satisfy a multi-signer threshold alone. + */ + addSignature( + requestId: string, + publicKey: string, + signature: string, + now: number = Date.now(), + ): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `addSignature: unknown signing request "${requestId}".`, + ); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + if (request.status === "executed") { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `addSignature: request "${requestId}" has already been executed.`, + ); + } + if (request.status === "expired") { + return err( + SorokitErrorCode.OPERATION_TIMEOUT, + `addSignature: request "${requestId}" expired and can no longer collect signatures.`, + ); + } + + const signer = request.signers.find((entry) => entry.publicKey === publicKey); + if (!signer) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: ${publicKey} is not a declared signer on this request.`, + ); + } + if (request.signatures.some((entry) => entry.publicKey === publicKey)) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: ${publicKey} has already signed this request.`, + ); + } + + if (!this.isSignatureValid(request, publicKey, signature)) { + return err( + SorokitErrorCode.WALLET_SIGN_FAILED, + `addSignature: signature from ${publicKey} is not a valid signature over this request payload.`, + ); + } + + const signatures = [ + ...request.signatures, + { publicKey, signature, weight: signer.weight, collectedAt: now }, + ]; + const collectedWeight = signatures.reduce((sum, entry) => sum + entry.weight, 0); + const updated = recompute({ ...request, signatures, collectedWeight }, now); + + this.requests.set(requestId, updated); + return ok(updated); + } + + private isSignatureValid( + request: ContractSigningRequest, + publicKey: string, + signature: string, + ): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const payload = Buffer.from(request.payloadHash, "hex"); + return keypair.verify(payload, Buffer.from(signature, "base64")); + } catch { + return false; + } + } + + /** Current collection state of a request. */ + getRequestState(requestId: string, now: number = Date.now()): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `getRequestState: unknown signing request "${requestId}".`, + ); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + const signedBy = request.signatures.map((entry) => entry.publicKey); + return ok({ + id: request.id, + status: request.status, + collectedWeight: request.collectedWeight, + threshold: request.threshold, + remainingWeight: Math.max(0, request.threshold - request.collectedWeight), + thresholdMet: request.collectedWeight >= request.threshold, + signedBy, + pendingSigners: request.signers + .filter((signer) => !signedBy.includes(signer.publicKey)) + .map((signer) => signer.publicKey), + ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), + }); + } + + /** Retrieve the full request record. */ + getRequest(requestId: string): ContractSigningRequest | undefined { + return this.requests.get(requestId); + } + + /** List every tracked request. */ + listRequests(): ContractSigningRequest[] { + return [...this.requests.values()]; + } + + /** + * Assemble the fully-signed transaction XDR once the threshold is met. + * + * Execution is blocked while the request is still collecting, expired, or + * already executed. On success the collected signatures are attached to the + * envelope and the request is marked executed, so the same authorization + * cannot be assembled twice. + * + * @returns `ok(signedXdr)` ready to pass to submitTransaction(). + */ + execute(requestId: string, now: number = Date.now()): SorokitResult { + const existing = this.requests.get(requestId); + if (!existing) { + return err(SorokitErrorCode.INVALID_CONFIG, `execute: unknown signing request "${requestId}".`); + } + + const request = recompute(existing, now); + this.requests.set(requestId, request); + + if (request.status === "executed") { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `execute: request "${requestId}" has already been executed.`, + ); + } + if (request.status === "expired") { + return err( + SorokitErrorCode.OPERATION_TIMEOUT, + `execute: request "${requestId}" expired before the threshold was met and cannot be executed.`, + ); + } + if (request.collectedWeight < request.threshold) { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + `execute: threshold not met — ${request.threshold - request.collectedWeight} more weight required ` + + `(collected ${request.collectedWeight}/${request.threshold}).`, + ); + } + + let signedXdr: string; + try { + const transaction = TransactionBuilder.fromXDR( + request.transactionXdr, + request.networkPassphrase, + ); + for (const entry of request.signatures) { + const hint = Keypair.fromPublicKey(entry.publicKey).signatureHint(); + transaction.signatures.push( + new xdr.DecoratedSignature({ + hint, + signature: Buffer.from(entry.signature, "base64"), + }), + ); + } + signedXdr = transaction.toXDR(); + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `execute: failed to assemble the signed envelope — ${toMessage(cause)}`, + cause, + ); + } + + this.requests.set(requestId, { ...request, status: "executed" }); + return ok(signedXdr); + } + + /** Discard a request. Returns true when one was removed. */ + cancelRequest(requestId: string): boolean { + return this.requests.delete(requestId); + } + + /** Discard all tracked requests. */ + clear(): void { + this.requests.clear(); + this.sequence = 0; + } +} + +/** Construct a {@link MultiSigContractExecution} workflow. */ +export function createMultiSigContractExecution(): MultiSigContractExecution { + return new MultiSigContractExecution(); +} diff --git a/src/tests/contractStateHistory.test.ts b/src/tests/contractStateHistory.test.ts new file mode 100644 index 0000000..f64e858 --- /dev/null +++ b/src/tests/contractStateHistory.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { + createContractStateHistory, + fingerprintState, + ContractStateHistory, +} from "../soroban/contractStateHistory"; + +const CONTRACT_A = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const CONTRACT_B = "CDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + +function historyWith(): ContractStateHistory { + return createContractStateHistory(); +} + +describe("fingerprintState", () => { + it("is stable for the same state", () => { + expect(fingerprintState({ a: 1, b: "two" })).toBe(fingerprintState({ a: 1, b: "two" })); + }); + + it("ignores key insertion order", () => { + expect(fingerprintState({ a: 1, b: 2 })).toBe(fingerprintState({ b: 2, a: 1 })); + }); + + it("ignores key order at nested depth", () => { + expect(fingerprintState({ outer: { x: 1, y: 2 } })).toBe( + fingerprintState({ outer: { y: 2, x: 1 } }), + ); + }); + + it("changes when a value changes", () => { + expect(fingerprintState({ a: 1 })).not.toBe(fingerprintState({ a: 2 })); + }); + + it("distinguishes a number from its string form", () => { + expect(fingerprintState({ a: 1 })).not.toBe(fingerprintState({ a: "1" })); + }); + + it("preserves array order", () => { + expect(fingerprintState({ a: [1, 2] })).not.toBe(fingerprintState({ a: [2, 1] })); + }); + + it("handles bigint values without throwing", () => { + expect(() => fingerprintState({ total: 10n })).not.toThrow(); + expect(fingerprintState({ total: 10n })).toBe(fingerprintState({ total: 10n })); + }); + + it("treats an empty state as valid", () => { + expect(fingerprintState({})).toMatch(/^[0-9a-f]{8}$/); + }); +}); + +describe("captureSnapshot", () => { + it("records contract id, ledger, timestamp and fingerprint", () => { + const history = historyWith(); + const result = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 100, + state: { counter: 1 }, + timestamp: 1_700_000_000_000, + }); + + expect(result.status).toBe("ok"); + expect(result.data).toMatchObject({ + contractId: CONTRACT_A, + ledger: 100, + timestamp: 1_700_000_000_000, + fingerprint: fingerprintState({ counter: 1 }), + }); + expect(result.data?.id).toBeTruthy(); + }); + + it("stores an optional label", () => { + const history = historyWith(); + const result = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: {}, + label: "pre-migration", + }); + + expect(result.data?.label).toBe("pre-migration"); + }); + + it("rejects an empty contract id", () => { + const result = historyWith().captureSnapshot({ contractId: " ", ledger: 1, state: {} }); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects a negative or non-integer ledger", () => { + const history = historyWith(); + expect(history.captureSnapshot({ contractId: CONTRACT_A, ledger: -1, state: {} }).status).toBe( + "error", + ); + expect(history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1.5, state: {} }).status).toBe( + "error", + ); + }); + + it("copies the supplied state so later mutation cannot alter the snapshot", () => { + const history = historyWith(); + const state: Record = { counter: 1 }; + const snapshot = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state }); + + state["counter"] = 999; + + expect(snapshot.data?.state["counter"]).toBe(1); + expect(history.verifySnapshotIntegrity(snapshot.data!.id).data?.valid).toBe(true); + }); + + it("supports multiple snapshots for the same contract", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 2 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 3, state: { v: 3 } }); + + expect(history.querySnapshots({ contractId: CONTRACT_A })).toHaveLength(3); + }); + + it("gives distinct ids to two captures at the same ledger", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 } }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 2 } }); + + expect(first.data?.id).not.toBe(second.data?.id); + }); +}); + +describe("querySnapshots", () => { + function seeded(): ContractStateHistory { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 10, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 20, state: { v: 2 } }); + history.captureSnapshot({ contractId: CONTRACT_B, ledger: 30, state: { v: 3 } }); + return history; + } + + it("filters by contract", () => { + expect(seeded().querySnapshots({ contractId: CONTRACT_B })).toHaveLength(1); + }); + + it("filters by ledger range inclusively", () => { + const found = seeded().querySnapshots({ contractId: CONTRACT_A, fromLedger: 10, toLedger: 10 }); + expect(found.map((s) => s.ledger)).toEqual([10]); + }); + + it("returns newest ledger first", () => { + expect(seeded().querySnapshots().map((s) => s.ledger)).toEqual([30, 20, 10]); + }); + + it("returns an empty list when nothing matches", () => { + expect(seeded().querySnapshots({ contractId: "CUNKNOWN" })).toEqual([]); + }); +}); + +describe("getSnapshotAtLedger", () => { + it("returns the snapshot captured at that ledger", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 42, state: { v: 1 } }); + + expect(history.getSnapshotAtLedger(CONTRACT_A, 42).data?.ledger).toBe(42); + }); + + it("reports unavailable history clearly rather than returning empty state", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 42, state: { v: 1 } }); + const result = history.getSnapshotAtLedger(CONTRACT_A, 41); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("CONTRACT_READ_FAILED"); + expect(result.error?.message).toContain("does not reconstruct historical ledger state"); + }); +}); + +describe("pinContractState", () => { + it("pins a contract to a captured version", () => { + const history = historyWith(); + const snapshot = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + const pin = history.pinContractState(CONTRACT_A, 7); + + expect(pin.status).toBe("ok"); + expect(pin.data).toMatchObject({ contractId: CONTRACT_A, snapshotId: snapshot.data!.id, ledger: 7 }); + }); + + it("resolves the pinned snapshot", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + history.pinContractState(CONTRACT_A, 7); + + expect(history.getPinnedState(CONTRACT_A).data?.state).toEqual({ v: 1 }); + }); + + it("refuses to pin a ledger that was never captured", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 7, state: { v: 1 } }); + const result = history.pinContractState(CONTRACT_A, 8); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("no snapshot captured"); + }); + + it("does not fall back to the nearest ledger when the exact one is missing", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 9, state: { v: 2 } }); + + expect(history.pinContractState(CONTRACT_A, 7).status).toBe("error"); + }); + + it("pins the most recent capture when a ledger was captured twice", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 5, state: { v: 1 }, timestamp: 1 }); + const later = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 5, + state: { v: 2 }, + timestamp: 2, + }); + + expect(history.pinContractState(CONTRACT_A, 5).data?.snapshotId).toBe(later.data?.id); + }); + + it("reports an unpinned contract", () => { + const result = historyWith().getPinnedState(CONTRACT_A); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not pinned"); + }); + + it("moves the pin when re-pinned to another version", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 2 } }); + history.pinContractState(CONTRACT_A, 1); + history.pinContractState(CONTRACT_A, 2); + + expect(history.getPinnedState(CONTRACT_A).data?.ledger).toBe(2); + }); + + it("unpins a contract", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + history.pinContractState(CONTRACT_A, 1); + + expect(history.unpinContractState(CONTRACT_A)).toBe(true); + expect(history.unpinContractState(CONTRACT_A)).toBe(false); + expect(history.getPin(CONTRACT_A)).toBeUndefined(); + }); + + it("keeps pins independent across contracts", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { a: 1 } }); + history.captureSnapshot({ contractId: CONTRACT_B, ledger: 2, state: { b: 1 } }); + history.pinContractState(CONTRACT_A, 1); + history.pinContractState(CONTRACT_B, 2); + + expect(history.getPinnedState(CONTRACT_A).data?.state).toEqual({ a: 1 }); + expect(history.getPinnedState(CONTRACT_B).data?.state).toEqual({ b: 1 }); + }); +}); + +describe("verifySnapshotIntegrity", () => { + it("validates an untouched snapshot", () => { + const history = historyWith(); + const snapshot = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { balance: 500 }, + }); + const report = history.verifySnapshotIntegrity(snapshot.data!.id); + + expect(report.data?.valid).toBe(true); + expect(report.data?.actualFingerprint).toBe(report.data?.expectedFingerprint); + }); + + it("reports an unknown snapshot as an error", () => { + const result = historyWith().verifySnapshotIntegrity("missing"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("CONTRACT_READ_FAILED"); + }); +}); + +describe("compareSnapshots", () => { + it("detects added, removed and changed entries", () => { + const history = historyWith(); + const before = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { kept: 1, dropped: 2, moved: 3 }, + }); + const after = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 2, + state: { kept: 1, moved: 4, fresh: 5 }, + }); + + const comparison = history.compareSnapshots(before.data!.id, after.data!.id); + + expect(comparison.data?.identical).toBe(false); + expect(comparison.data?.changes).toEqual([ + { key: "dropped", kind: "removed", from: 2 }, + { key: "fresh", kind: "added", to: 5 }, + { key: "moved", kind: "changed", from: 3, to: 4 }, + ]); + }); + + it("reports identical snapshots with no changes", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: { v: 1 } }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 2, state: { v: 1 } }); + + const comparison = history.compareSnapshots(first.data!.id, second.data!.id); + + expect(comparison.data?.identical).toBe(true); + expect(comparison.data?.changes).toEqual([]); + }); + + it("does not report a change when only nested key order differs", () => { + const history = historyWith(); + const first = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 1, + state: { cfg: { a: 1, b: 2 } }, + }); + const second = history.captureSnapshot({ + contractId: CONTRACT_A, + ledger: 2, + state: { cfg: { b: 2, a: 1 } }, + }); + + expect(history.compareSnapshots(first.data!.id, second.data!.id).data?.changes).toEqual([]); + }); + + it("carries both ledger references in the comparison", () => { + const history = historyWith(); + const first = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 10, state: {} }); + const second = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 20, state: {} }); + + expect(history.compareSnapshots(first.data!.id, second.data!.id).data).toMatchObject({ + fromLedger: 10, + toLedger: 20, + contractId: CONTRACT_A, + }); + }); + + it("rejects a comparison across two different contracts", () => { + const history = historyWith(); + const a = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + const b = history.captureSnapshot({ contractId: CONTRACT_B, ledger: 1, state: {} }); + + const result = history.compareSnapshots(a.data!.id, b.data!.id); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("different contracts"); + }); + + it("reports an unknown snapshot id", () => { + const history = historyWith(); + const known = history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + + expect(history.compareSnapshots("missing", known.data!.id).status).toBe("error"); + expect(history.compareSnapshots(known.data!.id, "missing").status).toBe("error"); + }); +}); + +describe("clear", () => { + it("discards snapshots and pins", () => { + const history = historyWith(); + history.captureSnapshot({ contractId: CONTRACT_A, ledger: 1, state: {} }); + history.pinContractState(CONTRACT_A, 1); + history.clear(); + + expect(history.querySnapshots()).toEqual([]); + expect(history.getPin(CONTRACT_A)).toBeUndefined(); + }); +}); diff --git a/src/tests/multiSigExecution.test.ts b/src/tests/multiSigExecution.test.ts new file mode 100644 index 0000000..e4d5901 --- /dev/null +++ b/src/tests/multiSigExecution.test.ts @@ -0,0 +1,498 @@ +import { + Account, + Keypair, + Networks, + Operation, + TransactionBuilder, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import { describe, expect, it } from "vitest"; +import { + createMultiSigContractExecution, + MultiSigContractExecution, +} from "../soroban/multiSigExecution"; +import type { ContractSigningRequest } from "../soroban/multiSigExecution"; + +const NETWORK = Networks.TESTNET; + +// Deterministic signers so failures are reproducible. +const SIGNER_A = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)); +const SIGNER_B = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)); +const SIGNER_C = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)); +const OUTSIDER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)); + +/** Build an unsigned transaction to stand in for a prepared contract invocation. */ +function buildUnsignedXdr(): string { + const source = new Account(SIGNER_A.publicKey(), "1"); + return new TransactionBuilder(source, { fee: BASE_FEE, networkPassphrase: NETWORK }) + .addOperation(Operation.bumpSequence({ bumpTo: "2" })) + .setTimeout(0) + .build() + .toXDR(); +} + +const UNSIGNED_XDR = buildUnsignedXdr(); + +/** Sign a request's canonical payload the way a wallet would. */ +function sign(request: ContractSigningRequest, keypair: Keypair): string { + return keypair.sign(Buffer.from(request.payloadHash, "hex")).toString("base64"); +} + +function workflow(): MultiSigContractExecution { + return createMultiSigContractExecution(); +} + +function newRequest( + execution: MultiSigContractExecution, + overrides: Partial[0]> = {}, +): ContractSigningRequest { + const result = execution.createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [ + { publicKey: SIGNER_A.publicKey() }, + { publicKey: SIGNER_B.publicKey() }, + { publicKey: SIGNER_C.publicKey() }, + ], + threshold: 2, + ...overrides, + }); + if (result.status === "error") throw new Error(result.error.message); + return result.data; +} + +describe("createSigningRequest", () => { + it("creates a request carrying a canonical payload derived from the transaction", () => { + const request = newRequest(workflow()); + const expected = TransactionBuilder.fromXDR(UNSIGNED_XDR, NETWORK).hash().toString("hex"); + + expect(request.payloadHash).toBe(expected); + expect(request.status).toBe("collecting"); + expect(request.collectedWeight).toBe(0); + }); + + it("supports N-of-M configuration with explicit weights", () => { + const request = newRequest(workflow(), { + signers: [ + { publicKey: SIGNER_A.publicKey(), weight: 2 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ], + threshold: 3, + }); + + expect(request.signers).toEqual([ + { publicKey: SIGNER_A.publicKey(), weight: 2 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ]); + expect(request.threshold).toBe(3); + }); + + it("defaults an omitted signer weight to 1", () => { + expect(newRequest(workflow()).signers.every((s) => s.weight === 1)).toBe(true); + }); + + it("rejects an empty signer list", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("at least one signer"); + }); + + it("rejects a duplicate signer in the configuration", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }, { publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("listed more than once"); + }); + + it("rejects an unreachable threshold", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 5, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("can never be met"); + }); + + it("rejects a threshold below 1", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 0, + }); + + expect(result.status).toBe("error"); + }); + + it("rejects a non-positive signer weight", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey(), weight: 0 }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("invalid weight"); + }); + + it("rejects a malformed signer public key", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: "not-a-key" }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_ADDRESS"); + }); + + it("rejects malformed transaction XDR", () => { + const result = workflow().createSigningRequest({ + transactionXdr: "garbage", + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("TX_BUILD_FAILED"); + }); + + it("rejects an expiry that is already in the past", () => { + const result = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: NETWORK, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + now: 1_000, + expiresAt: 500, + }); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("must be in the future"); + }); + + it("binds the payload to the network passphrase", () => { + const testnet = newRequest(workflow()); + const futurenet = workflow().createSigningRequest({ + transactionXdr: UNSIGNED_XDR, + networkPassphrase: Networks.FUTURENET, + signers: [{ publicKey: SIGNER_A.publicKey() }], + threshold: 1, + }); + + expect(futurenet.data?.payloadHash).not.toBe(testnet.payloadHash); + }); +}); + +describe("addSignature", () => { + it("accepts a valid signature and credits its weight", () => { + const execution = workflow(); + const request = newRequest(execution); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(result.status).toBe("ok"); + expect(result.data?.collectedWeight).toBe(1); + expect(result.data?.signatures).toHaveLength(1); + }); + + it("rejects a signature that does not verify and credits no weight", () => { + const execution = workflow(); + const request = newRequest(execution); + + // A real signature, but produced by a different key than the one claimed. + const forged = sign(request, OUTSIDER); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), forged); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not a valid signature"); + expect(execution.getRequestState(request.id).data?.collectedWeight).toBe(0); + }); + + it("rejects a signature over a different payload", () => { + const execution = workflow(); + const request = newRequest(execution); + const wrongPayload = SIGNER_A.sign(Buffer.from("some other payload")).toString("base64"); + + expect(execution.addSignature(request.id, SIGNER_A.publicKey(), wrongPayload).status).toBe( + "error", + ); + }); + + it("rejects structurally invalid signature bytes", () => { + const execution = workflow(); + const request = newRequest(execution); + + expect(execution.addSignature(request.id, SIGNER_A.publicKey(), "!!!not-base64!!!").status).toBe( + "error", + ); + }); + + it("rejects a signer that is not declared on the request", () => { + const execution = workflow(); + const request = newRequest(execution); + const result = execution.addSignature( + request.id, + OUTSIDER.publicKey(), + sign(request, OUTSIDER), + ); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not a declared signer"); + }); + + it("rejects a duplicate submission from the same signer", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + const duplicate = execution.addSignature( + request.id, + SIGNER_A.publicKey(), + sign(request, SIGNER_A), + ); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already signed"); + expect(execution.getRequestState(request.id).data?.collectedWeight).toBe(1); + }); + + it("does not let one signer alone satisfy a two-signer threshold", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data?.thresholdMet).toBe(false); + expect(execution.execute(request.id).status).toBe("error"); + }); + + it("marks the request ready once the threshold is met", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + const second = execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + expect(second.data?.status).toBe("ready"); + expect(second.data?.collectedWeight).toBe(2); + }); + + it("reaches the threshold on weight, not signer count", () => { + const execution = workflow(); + const request = newRequest(execution, { + signers: [ + { publicKey: SIGNER_A.publicKey(), weight: 3 }, + { publicKey: SIGNER_B.publicKey(), weight: 1 }, + ], + threshold: 3, + }); + const result = execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(result.data?.status).toBe("ready"); + }); + + it("rejects a signature on an unknown request", () => { + expect(workflow().addSignature("missing", SIGNER_A.publicKey(), "sig").status).toBe("error"); + }); + + it("refuses signatures once the request has expired", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + const result = execution.addSignature( + request.id, + SIGNER_A.publicKey(), + sign(request, SIGNER_A), + 1_001, + ); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("OPERATION_TIMEOUT"); + }); +}); + +describe("getRequestState", () => { + it("reports collection progress and who is still pending", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data).toMatchObject({ + status: "collecting", + collectedWeight: 1, + threshold: 2, + remainingWeight: 1, + thresholdMet: false, + signedBy: [SIGNER_A.publicKey()], + pendingSigners: [SIGNER_B.publicKey(), SIGNER_C.publicKey()], + }); + }); + + it("clamps remaining weight at zero once satisfied", () => { + const execution = workflow(); + const request = newRequest(execution, { + signers: [{ publicKey: SIGNER_A.publicKey(), weight: 5 }], + threshold: 2, + }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + expect(execution.getRequestState(request.id).data?.remainingWeight).toBe(0); + }); + + it("transitions to expired once the deadline passes", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + + expect(execution.getRequestState(request.id, 999).data?.status).toBe("collecting"); + expect(execution.getRequestState(request.id, 1_000).data?.status).toBe("expired"); + }); + + it("reports an unknown request as an error", () => { + expect(workflow().getRequestState("missing").status).toBe("error"); + }); +}); + +describe("execute", () => { + it("blocks execution while the threshold is unmet", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + + const result = execution.execute(request.id); + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("threshold not met"); + }); + + it("blocks execution with no signatures at all", () => { + const execution = workflow(); + expect(execution.execute(newRequest(execution).id).status).toBe("error"); + }); + + it("assembles a signed envelope carrying every collected signature", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + const result = execution.execute(request.id); + expect(result.status).toBe("ok"); + + const signed = TransactionBuilder.fromXDR(result.data!, NETWORK); + expect(signed.signatures).toHaveLength(2); + }); + + it("produces signatures that verify against the signers", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + + const signed = TransactionBuilder.fromXDR(execution.execute(request.id).data!, NETWORK); + const payload = signed.hash(); + + for (const keypair of [SIGNER_A, SIGNER_B]) { + const match = signed.signatures.find((s) => + s.hint().equals(keypair.signatureHint()), + ); + expect(match).toBeDefined(); + expect(keypair.verify(payload, match!.signature())).toBe(true); + } + }); + + it("marks the request executed and refuses a second execution", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + execution.execute(request.id); + + expect(execution.getRequestState(request.id).data?.status).toBe("executed"); + const again = execution.execute(request.id); + expect(again.status).toBe("error"); + expect(again.error?.message).toContain("already been executed"); + }); + + it("refuses further signatures after execution", () => { + const execution = workflow(); + const request = newRequest(execution); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A)); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B)); + execution.execute(request.id); + + const late = execution.addSignature(request.id, SIGNER_C.publicKey(), sign(request, SIGNER_C)); + expect(late.status).toBe("error"); + expect(late.error?.message).toContain("already been executed"); + }); + + it("refuses to execute an expired request even when the threshold was met", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A), 10); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B), 20); + + expect(execution.getRequestState(request.id, 20).data?.thresholdMet).toBe(true); + + const result = execution.execute(request.id, 1_001); + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("OPERATION_TIMEOUT"); + }); + + it("executes normally before the deadline", () => { + const execution = workflow(); + const request = newRequest(execution, { now: 0, expiresAt: 1_000 }); + execution.addSignature(request.id, SIGNER_A.publicKey(), sign(request, SIGNER_A), 10); + execution.addSignature(request.id, SIGNER_B.publicKey(), sign(request, SIGNER_B), 20); + + expect(execution.execute(request.id, 999).status).toBe("ok"); + }); + + it("reports an unknown request", () => { + expect(workflow().execute("missing").status).toBe("error"); + }); +}); + +describe("request lifecycle", () => { + it("lists and cancels tracked requests", () => { + const execution = workflow(); + const request = newRequest(execution); + + expect(execution.listRequests()).toHaveLength(1); + expect(execution.cancelRequest(request.id)).toBe(true); + expect(execution.cancelRequest(request.id)).toBe(false); + expect(execution.getRequest(request.id)).toBeUndefined(); + }); + + it("keeps concurrent requests independent", () => { + const execution = workflow(); + const first = newRequest(execution); + const second = newRequest(execution); + + expect(first.id).not.toBe(second.id); + execution.addSignature(first.id, SIGNER_A.publicKey(), sign(first, SIGNER_A)); + + expect(execution.getRequestState(first.id).data?.collectedWeight).toBe(1); + expect(execution.getRequestState(second.id).data?.collectedWeight).toBe(0); + }); + + it("clears all tracked requests", () => { + const execution = workflow(); + newRequest(execution); + execution.clear(); + + expect(execution.listRequests()).toEqual([]); + }); +}); diff --git a/src/tests/spendingPolicy.test.ts b/src/tests/spendingPolicy.test.ts new file mode 100644 index 0000000..fb42218 --- /dev/null +++ b/src/tests/spendingPolicy.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from "vitest"; +import { + createSpendingPolicyEngine, + SpendingPolicyEngine, +} from "../transaction/spendingPolicy"; +import type { SpendingPolicyConfig } from "../transaction/spendingPolicy"; + +const NATIVE = "native"; +const USDC = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const DEST_A = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const DEST_B = "GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3"; + +function engineWith(config?: SpendingPolicyConfig): SpendingPolicyEngine { + return createSpendingPolicyEngine(config); +} + +// A fixed instant so daily/monthly window maths stay deterministic. +const T = Date.UTC(2026, 4, 15, 12, 0, 0); +const DAY = 24 * 60 * 60 * 1000; + +describe("setSpendingLimit", () => { + it("stores a limit and returns the normalized amount", () => { + const engine = engineWith(); + const result = engine.setSpendingLimit(NATIVE, "100.5000000", "daily"); + + expect(result.status).toBe("ok"); + expect(result.data).toEqual({ asset: NATIVE, amount: "100.5", period: "daily" }); + expect(engine.listSpendingLimits()).toHaveLength(1); + }); + + it("rejects a malformed amount", () => { + const engine = engineWith(); + const result = engine.setSpendingLimit(NATIVE, "not-a-number", "daily"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an amount with more precision than stroops allow", () => { + const engine = engineWith(); + expect(engine.setSpendingLimit(NATIVE, "1.123456789", "daily").status).toBe("error"); + }); + + it("rejects an empty asset", () => { + expect(engineWith().setSpendingLimit(" ", "10", "daily").status).toBe("error"); + }); + + it("replaces the ceiling when the same asset and period are reconfigured", () => { + const engine = engineWith(); + engine.setSpendingLimit(NATIVE, "100", "daily"); + engine.setSpendingLimit(NATIVE, "250", "daily"); + + expect(engine.listSpendingLimits()).toEqual([ + { asset: NATIVE, amount: "250", period: "daily" }, + ]); + }); + + it("removes a configured limit", () => { + const engine = engineWith(); + engine.setSpendingLimit(NATIVE, "100", "daily"); + + expect(engine.removeSpendingLimit(NATIVE, "daily")).toBe(true); + expect(engine.removeSpendingLimit(NATIVE, "daily")).toBe(false); + expect(engine.listSpendingLimits()).toHaveLength(0); + }); +}); + +describe("per-transaction limits", () => { + it("allows a transaction at exactly the limit", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + expect(result.data?.violations).toEqual([]); + }); + + it("denies a transaction above the limit with a structured violation", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100.0000001", timestamp: T }); + + expect(result.data?.decision).toBe("denied"); + expect(result.data?.violations[0]).toMatchObject({ + code: "PER_TRANSACTION_LIMIT_EXCEEDED", + asset: NATIVE, + limit: "100", + requested: "100.0000001", + }); + }); + + it("does not accumulate across separate transactions", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "per_transaction" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T }); + + expect(second.data?.decision).toBe("allowed"); + }); +}); + +describe("cumulative daily and monthly limits", () => { + it("denies the request that would push the day over its ceiling", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "60", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "50", timestamp: T }); + + expect(second.data?.decision).toBe("denied"); + expect(second.data?.violations[0]).toMatchObject({ + code: "DAILY_LIMIT_EXCEEDED", + used: "60", + requested: "50", + limit: "100", + }); + }); + + it("resets the daily window on the next UTC day", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const nextDay = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T + DAY }); + + expect(nextDay.data?.decision).toBe("allowed"); + }); + + it("keeps the monthly window accruing across days", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "150", period: "monthly" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const nextDay = engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T + DAY }); + + expect(nextDay.data?.decision).toBe("denied"); + expect(nextDay.data?.violations[0]?.code).toBe("MONTHLY_LIMIT_EXCEEDED"); + }); + + it("resets the monthly window in the following month", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "150", period: "monthly" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const nextMonth = engine.evaluate({ + id: "r2", + asset: NATIVE, + amount: "150", + timestamp: Date.UTC(2026, 5, 1, 0, 0, 0), + }); + + expect(nextMonth.data?.decision).toBe("allowed"); + }); + + it("reports every breached window at once", () => { + const engine = engineWith({ + limits: [ + { asset: NATIVE, amount: "10", period: "per_transaction" }, + { asset: NATIVE, amount: "10", period: "daily" }, + { asset: NATIVE, amount: "10", period: "monthly" }, + ], + }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "50", timestamp: T }); + + expect(result.data?.violations.map((v) => v.code)).toEqual([ + "PER_TRANSACTION_LIMIT_EXCEEDED", + "DAILY_LIMIT_EXCEEDED", + "MONTHLY_LIMIT_EXCEEDED", + ]); + }); +}); + +describe("per-asset limit isolation", () => { + it("does not let one asset consume another asset's ceiling", () => { + const engine = engineWith({ + limits: [ + { asset: NATIVE, amount: "100", period: "daily" }, + { asset: USDC, amount: "100", period: "daily" }, + ], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + const usdc = engine.evaluate({ id: "r2", asset: USDC, amount: "100", timestamp: T }); + + expect(usdc.data?.decision).toBe("allowed"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("100"); + expect(engine.getSpendingUsage(USDC, T).daily).toBe("100"); + }); + + it("leaves an unconfigured asset unconstrained", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "1", period: "daily" }] }); + const result = engine.evaluate({ id: "r1", asset: USDC, amount: "999999", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + }); +}); + +describe("concurrent requests", () => { + it("counts an authorized-but-unsubmitted request against the ceiling", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + + // Neither request has been submitted yet — the second must still see the first. + const first = engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + + expect(first.data?.decision).toBe("allowed"); + expect(second.data?.decision).toBe("denied"); + }); + + it("counts a pending-approval request against the ceiling", () => { + const engine = engineWith({ + limits: [{ asset: NATIVE, amount: "100", period: "daily" }], + approvalThresholds: [{ asset: NATIVE, amount: "10" }], + }); + const first = engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + const second = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + + expect(first.data?.decision).toBe("requires_approval"); + expect(second.data?.decision).toBe("denied"); + }); + + it("releases reserved capacity when a request is rejected", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.rejectRequest("r1"); + + const retry = engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }); + expect(retry.data?.decision).toBe("allowed"); + }); + + it("releases reserved capacity when a request fails", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.markFailed("r1"); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }).data?.decision).toBe( + "allowed", + ); + }); + + it("keeps capacity consumed once a request completes", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "70", timestamp: T }); + engine.markCompleted("r1"); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("70"); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "70", timestamp: T }).data?.decision).toBe( + "denied", + ); + }); + + it("rejects a duplicate request id rather than double-reserving", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + const duplicate = engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already been evaluated"); + }); + + it("does not reserve capacity for a denied request", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "10", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "50", timestamp: T }); + + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + }); +}); + +describe("destination restrictions", () => { + it("denies a destination outside the allow list", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "1", + destination: DEST_B, + timestamp: T, + }); + + expect(result.data?.decision).toBe("denied"); + expect(result.data?.violations[0]?.code).toBe("DESTINATION_NOT_ALLOWED"); + }); + + it("permits a destination inside the allow list", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "1", + destination: DEST_A, + timestamp: T, + }); + + expect(result.data?.decision).toBe("allowed"); + }); + + it("denies a destination on the deny list and permits others", () => { + const engine = engineWith({ + destinationRestriction: { mode: "deny", destinations: [DEST_A] }, + }); + + expect( + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", destination: DEST_A, timestamp: T }).data + ?.violations[0]?.code, + ).toBe("DESTINATION_DENIED"); + expect( + engine.evaluate({ id: "r2", asset: NATIVE, amount: "1", destination: DEST_B, timestamp: T }).data + ?.decision, + ).toBe("allowed"); + }); + + it("clears a restriction when set to undefined", () => { + const engine = engineWith({ + destinationRestriction: { mode: "allow", destinations: [DEST_A] }, + }); + engine.setDestinationRestriction(undefined); + + expect( + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", destination: DEST_B, timestamp: T }).data + ?.decision, + ).toBe("allowed"); + }); +}); + +describe("approval workflow", () => { + it("routes a transaction above the threshold into an approval state", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(result.data?.decision).toBe("requires_approval"); + expect(result.data?.requiredApprovers).toBe(1); + expect(engine.getRequest("r1")?.status).toBe("pending_approval"); + }); + + it("leaves a transaction at or below the threshold immediately allowed", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + const result = engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + + expect(result.data?.decision).toBe("allowed"); + expect(result.data?.requiredApprovers).toBeUndefined(); + }); + + it("authorizes once a single approval arrives", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const approved = engine.approveRequest("r1", "alice"); + + expect(approved.data?.status).toBe("authorized"); + expect(approved.data?.approvals).toEqual(["alice"]); + }); + + it("holds a multi-approver request until every approval is collected", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", requiredApprovers: 3 }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.approveRequest("r1", "alice").data?.status).toBe("pending_approval"); + expect(engine.approveRequest("r1", "bob").data?.status).toBe("pending_approval"); + expect(engine.approveRequest("r1", "carol").data?.status).toBe("authorized"); + expect(engine.getRequest("r1")?.approvals).toEqual(["alice", "bob", "carol"]); + }); + + it("rejects a duplicate approval so one approver cannot satisfy the threshold alone", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", requiredApprovers: 2 }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + engine.approveRequest("r1", "alice"); + const duplicate = engine.approveRequest("r1", "alice"); + + expect(duplicate.status).toBe("error"); + expect(duplicate.error?.message).toContain("already approved"); + expect(engine.getRequest("r1")?.status).toBe("pending_approval"); + }); + + it("rejects an approver outside the configured approver set", () => { + const engine = engineWith({ + approvalThresholds: [{ asset: NATIVE, amount: "100", approvers: ["alice"] }], + }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const result = engine.approveRequest("r1", "mallory"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not an authorized approver"); + }); + + it("rejects an approval for an unknown request", () => { + expect(engineWith().approveRequest("nope", "alice").status).toBe("error"); + }); + + it("rejects an approval for a request that is not pending", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + const result = engine.approveRequest("r1", "alice"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("not pending approval"); + }); + + it("rejects a configured threshold with fewer than one approver", () => { + const engine = engineWith(); + expect(engine.setApprovalThreshold({ asset: NATIVE, amount: "10", requiredApprovers: 0 }).status).toBe( + "error", + ); + }); + + it("can reject a pending request outright", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.rejectRequest("r1").data?.status).toBe("rejected"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("0"); + }); + + it("refuses to complete a request still awaiting approval", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "150", timestamp: T }); + const result = engine.markCompleted("r1"); + + expect(result.status).toBe("error"); + expect(result.error?.message).toContain("only authorized requests can complete"); + }); +}); + +describe("usage reporting and lifecycle", () => { + it("reports the largest single spend as per-transaction usage", () => { + const engine = engineWith(); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "10", timestamp: T }); + engine.evaluate({ id: "r2", asset: NATIVE, amount: "45", timestamp: T }); + + const usage = engine.getSpendingUsage(NATIVE, T); + expect(usage.perTransaction).toBe("45"); + expect(usage.daily).toBe("55"); + expect(usage.monthly).toBe("55"); + }); + + it("filters listed requests by status", () => { + const engine = engineWith({ approvalThresholds: [{ asset: NATIVE, amount: "100" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "1", timestamp: T }); + engine.evaluate({ id: "r2", asset: NATIVE, amount: "150", timestamp: T }); + + expect(engine.listRequests("authorized").map((r) => r.id)).toEqual(["r1"]); + expect(engine.listRequests("pending_approval").map((r) => r.id)).toEqual(["r2"]); + expect(engine.listRequests()).toHaveLength(2); + }); + + it("clears records but retains limits on reset", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "100", period: "daily" }] }); + engine.evaluate({ id: "r1", asset: NATIVE, amount: "100", timestamp: T }); + engine.reset(); + + expect(engine.listRequests()).toHaveLength(0); + expect(engine.listSpendingLimits()).toHaveLength(1); + expect(engine.evaluate({ id: "r2", asset: NATIVE, amount: "100", timestamp: T }).data?.decision).toBe( + "allowed", + ); + }); + + it("rejects a malformed request amount", () => { + const result = engineWith().evaluate({ id: "r1", asset: NATIVE, amount: "-5", timestamp: T }); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe("INVALID_CONFIG"); + }); + + it("rejects an empty request id", () => { + expect(engineWith().evaluate({ id: " ", asset: NATIVE, amount: "1" }).status).toBe("error"); + }); + + it("preserves precision on large amounts beyond the safe integer range", () => { + const engine = engineWith({ limits: [{ asset: NATIVE, amount: "922337203685.4775807", period: "daily" }] }); + const result = engine.evaluate({ + id: "r1", + asset: NATIVE, + amount: "922337203685.4775807", + timestamp: T, + }); + + expect(result.data?.decision).toBe("allowed"); + expect(engine.getSpendingUsage(NATIVE, T).daily).toBe("922337203685.4775807"); + }); +}); diff --git a/src/tests/walletSecurityAudit.test.ts b/src/tests/walletSecurityAudit.test.ts new file mode 100644 index 0000000..e638d7f --- /dev/null +++ b/src/tests/walletSecurityAudit.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from "vitest"; +import { auditWalletSecurity, isHighRiskConnection } from "../wallet/securityAudit"; +import type { VulnerabilitySource, WalletSecurityAuditOptions } from "../wallet/securityAudit"; +import { WalletType } from "../wallet/types"; +import type { WalletAdapter, WalletCapabilities, WalletCapabilityId } from "../wallet/types"; +import { ok } from "../shared/response"; + +const NOW = Date.UTC(2026, 4, 15); +const DAY = 24 * 60 * 60 * 1000; + +function capabilities( + walletType: WalletType, + supported: readonly WalletCapabilityId[], +): WalletCapabilities { + const ids: WalletCapabilityId[] = [ + "account.read", + "transaction.sign", + "transaction.sign_multisig", + "transaction.sign_soroban", + ]; + const list = ids.map((id) => ({ + id, + supported: supported.includes(id), + source: "adapter" as const, + })); + return { + walletType, + capabilities: list, + supports: (capability: string) => + list.some((entry) => entry.id === capability && entry.supported), + }; +} + +interface AdapterOverrides { + walletType?: WalletType; + available?: boolean; + supported?: readonly WalletCapabilityId[]; + getCapabilities?: () => WalletCapabilities; +} + +function makeAdapter(overrides: AdapterOverrides = {}): WalletAdapter { + const walletType = overrides.walletType ?? WalletType.FREIGHTER; + const supported = overrides.supported ?? [ + "account.read", + "transaction.sign", + "transaction.sign_multisig", + "transaction.sign_soroban", + ]; + return { + walletType, + isAvailable: () => overrides.available ?? true, + connect: async () => ok("GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"), + disconnect: async () => ok(undefined), + signTransaction: async () => ok("signed"), + getCapabilities: overrides.getCapabilities ?? (() => capabilities(walletType, supported)), + }; +} + +const CLEAN_SOURCE: VulnerabilitySource = { + name: "test-advisories", + vulnerabilities: [], + knownWallets: [WalletType.FREIGHTER, WalletType.XBULL], + updatedAt: NOW, +}; + +/** A fully healthy configuration: HTTPS, full capabilities, fresh clean source. */ +function secureOptions(): WalletSecurityAuditOptions { + return { + now: NOW, + connection: { + connected: true, + publicKey: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + origin: "https://app.example.com", + adapterVersion: "1.2.3", + }, + vulnerabilitySource: CLEAN_SOURCE, + }; +} + +function factorIds(report: ReturnType): string[] { + return report.factors.map((factor) => factor.id); +} + +describe("secure configuration", () => { + it("scores at the top of the range with no penalties", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.score).toBe(100); + expect(report.riskLevel).toBe("low"); + expect(report.warnings).toEqual([]); + }); + + it("reports the wallet type and assessed capabilities", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.walletType).toBe(WalletType.FREIGHTER); + expect(report.capabilities).toContainEqual({ id: "transaction.sign", supported: true }); + }); + + it("records a secure origin as an informational factor", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(factorIds(report)).toContain("origin.secure"); + }); + + it("marks vulnerability data as available", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.vulnerabilityDataAvailable).toBe(true); + expect(report.matchedVulnerabilities).toEqual([]); + }); + + it("does not claim a clean source proves safety", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + const factor = report.factors.find((entry) => entry.id === "vulnerability.none.known"); + + expect(factor?.summary).toContain("not proof of safety"); + }); + + it("is not flagged as high risk", () => { + expect(isHighRiskConnection(auditWalletSecurity(makeAdapter(), secureOptions()))).toBe(false); + }); +}); + +describe("degraded configuration", () => { + it("penalizes a missing signing capability as critical", () => { + const adapter = makeAdapter({ supported: ["account.read"] }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(factorIds(report)).toContain("capability.transaction.sign.missing"); + expect(report.score).toBeLessThan(50); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("applies a smaller penalty for a missing optional capability", () => { + const adapter = makeAdapter({ + supported: ["account.read", "transaction.sign", "transaction.sign_soroban"], + }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(report.score).toBe(95); + expect(report.riskLevel).toBe("low"); + }); + + it("penalizes an unavailable adapter", () => { + const report = auditWalletSecurity(makeAdapter({ available: false }), secureOptions()); + + expect(factorIds(report)).toContain("adapter.unavailable"); + expect(report.score).toBe(85); + }); + + it("treats an adapter whose availability check throws as unavailable", () => { + const adapter = makeAdapter(); + const throwing: WalletAdapter = { + ...adapter, + isAvailable: () => { + throw new Error("boom"); + }, + }; + + expect(factorIds(auditWalletSecurity(throwing, secureOptions()))).toContain( + "adapter.unavailable", + ); + }); + + it("penalizes a plain HTTP origin as high severity", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "http://app.example.com" }, + }); + + expect(factorIds(report)).toContain("origin.insecure"); + expect(report.score).toBe(70); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("treats HTTP on localhost as a development-only low risk", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "http://localhost:3000" }, + }); + + expect(factorIds(report)).toContain("origin.localhost"); + expect(report.score).toBe(95); + }); + + it("penalizes an unparseable origin", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, origin: "not a url" }, + }); + + expect(factorIds(report)).toContain("origin.unparseable"); + }); + + it("flags a connection that reports connected with no public key", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, connected: true, publicKey: null }, + }); + + expect(factorIds(report)).toContain("connection.unauthenticated"); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("continues the assessment when capabilities cannot be read", () => { + const adapter = makeAdapter({ + getCapabilities: () => { + throw new Error("adapter exploded"); + }, + }); + const report = auditWalletSecurity(adapter, secureOptions()); + + expect(factorIds(report)).toContain("capabilities.unreadable"); + expect(report.capabilities).toEqual([]); + }); + + it("notes when capability values were inferred rather than reported", () => { + // No getCapabilities — the shared helper falls back to its static table. + const adapter = makeAdapter(); + const withoutCapabilities: WalletAdapter = { ...adapter }; + delete (withoutCapabilities as { getCapabilities?: unknown }).getCapabilities; + + const report = auditWalletSecurity(withoutCapabilities, secureOptions()); + expect(factorIds(report)).toContain("capabilities.inferred"); + }); +}); + +describe("vulnerable configuration", () => { + const vulnerableSource: VulnerabilitySource = { + name: "test-advisories", + knownWallets: [WalletType.FREIGHTER], + updatedAt: NOW, + vulnerabilities: [ + { + id: "CVE-2026-0001", + walletType: WalletType.FREIGHTER, + severity: "critical", + summary: "Signature request origin is not validated.", + affectedVersions: ["1.2.3"], + }, + ], + }; + + it("matches a vulnerability affecting the connected version", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: vulnerableSource, + }); + + expect(report.matchedVulnerabilities.map((v) => v.id)).toEqual(["CVE-2026-0001"]); + expect(report.score).toBe(50); + expect(isHighRiskConnection(report)).toBe(true); + }); + + it("surfaces the vulnerability as a warning", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: vulnerableSource, + }); + + expect(report.warnings.some((w) => w.includes("CVE-2026-0001"))).toBe(true); + }); + + it("does not match a version outside the affected range", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, adapterVersion: "9.9.9" }, + vulnerabilitySource: vulnerableSource, + }); + + expect(report.matchedVulnerabilities).toEqual([]); + expect(report.score).toBe(100); + }); + + it("does not match a vulnerability for a different wallet", () => { + const report = auditWalletSecurity(makeAdapter({ walletType: WalletType.XBULL }), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + knownWallets: [WalletType.FREIGHTER, WalletType.XBULL], + }, + }); + + expect(report.matchedVulnerabilities).toEqual([]); + }); + + it("treats an advisory with no version constraint as applying to all versions", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + vulnerabilities: [ + { + id: "CVE-2026-0002", + walletType: WalletType.FREIGHTER, + severity: "high", + summary: "Affects every released version.", + }, + ], + }, + }); + + expect(report.matchedVulnerabilities.map((v) => v.id)).toEqual(["CVE-2026-0002"]); + }); + + it("accumulates penalties across multiple vulnerabilities", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { + ...vulnerableSource, + vulnerabilities: [ + { + id: "A", + walletType: WalletType.FREIGHTER, + severity: "high", + summary: "First issue.", + }, + { + id: "B", + walletType: WalletType.FREIGHTER, + severity: "medium", + summary: "Second issue.", + }, + ], + }, + }); + + expect(report.matchedVulnerabilities).toHaveLength(2); + expect(report.score).toBe(55); + }); + + it("never scores below zero", () => { + const many = Array.from({ length: 10 }, (_, index) => ({ + id: `CVE-${index}`, + walletType: WalletType.FREIGHTER, + severity: "critical" as const, + summary: "Severe issue.", + })); + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...vulnerableSource, vulnerabilities: many }, + }); + + expect(report.score).toBe(0); + expect(report.riskLevel).toBe("high"); + }); +}); + +describe("unknown configuration", () => { + it("does not treat a missing vulnerability source as safe", () => { + const options = secureOptions(); + delete options.vulnerabilitySource; + const report = auditWalletSecurity(makeAdapter(), options); + + expect(report.vulnerabilityDataAvailable).toBe(false); + expect(factorIds(report)).toContain("vulnerability.source.absent"); + expect(report.score).toBeLessThan(100); + }); + + it("marks a wallet the source has not been checked against as unknown", () => { + const report = auditWalletSecurity(makeAdapter({ walletType: WalletType.RABET }), { + ...secureOptions(), + vulnerabilitySource: CLEAN_SOURCE, + }); + + expect(report.vulnerabilityDataAvailable).toBe(false); + expect(factorIds(report)).toContain("vulnerability.wallet.uncovered"); + }); + + it("penalizes an unknown adapter version against version-scoped advisories", () => { + const options = secureOptions(); + const report = auditWalletSecurity(makeAdapter(), { + ...options, + connection: { ...options.connection, adapterVersion: undefined }, + vulnerabilitySource: { + ...CLEAN_SOURCE, + vulnerabilities: [ + { + id: "CVE-2026-0003", + walletType: WalletType.XBULL, + severity: "high", + summary: "Other wallet issue.", + affectedVersions: ["0.1.0"], + }, + ], + }, + }); + + expect(factorIds(report)).toContain("vulnerability.version.unknown"); + }); + + it("penalizes a source with no update timestamp", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { name: "undated", vulnerabilities: [], knownWallets: [WalletType.FREIGHTER] }, + }); + + expect(factorIds(report)).toContain("vulnerability.source.undated"); + }); + + it("penalizes a stale source", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...CLEAN_SOURCE, updatedAt: NOW - 40 * DAY }, + }); + + expect(factorIds(report)).toContain("vulnerability.source.stale"); + }); + + it("respects a custom staleness window", () => { + const report = auditWalletSecurity(makeAdapter(), { + ...secureOptions(), + vulnerabilitySource: { ...CLEAN_SOURCE, updatedAt: NOW - 2 * DAY }, + maxSourceAgeMs: DAY, + }); + + expect(factorIds(report)).toContain("vulnerability.source.stale"); + }); + + it("records that origin could not be evaluated when none is supplied", () => { + const report = auditWalletSecurity(makeAdapter(), { + now: NOW, + vulnerabilitySource: CLEAN_SOURCE, + }); + + expect(factorIds(report)).toContain("origin.unavailable"); + }); +}); + +describe("report structure", () => { + it("is deterministic across repeated audits of identical input", () => { + const first = auditWalletSecurity(makeAdapter(), secureOptions()); + const second = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(first.score).toBe(second.score); + expect(factorIds(first)).toEqual(factorIds(second)); + }); + + it("keeps the score within 0 and 100", () => { + const report = auditWalletSecurity(makeAdapter({ supported: [] }), { + now: NOW, + connection: { connected: true, publicKey: null, origin: "http://evil.example.com" }, + }); + + expect(report.score).toBeGreaterThanOrEqual(0); + expect(report.score).toBeLessThanOrEqual(100); + }); + + it("exposes the individual factors that produced the score", () => { + const report = auditWalletSecurity(makeAdapter({ supported: ["account.read"] }), secureOptions()); + const penalties = report.factors.filter((factor) => factor.scoreDelta < 0); + + expect(penalties.length).toBeGreaterThan(0); + expect(report.score).toBe( + 100 + report.factors.reduce((total, factor) => total + factor.scoreDelta, 0), + ); + }); + + it("excludes informational factors from warnings", () => { + const report = auditWalletSecurity(makeAdapter(), secureOptions()); + + expect(report.factors.some((factor) => factor.severity === "info")).toBe(true); + expect(report.warnings).toEqual([]); + }); + + it("marks every score delta as a non-positive penalty", () => { + const report = auditWalletSecurity(makeAdapter({ supported: [] }), secureOptions()); + + expect(report.factors.every((factor) => factor.scoreDelta <= 0)).toBe(true); + }); + + it("maps scores onto risk bands", () => { + const high = auditWalletSecurity(makeAdapter({ supported: [] }), { + now: NOW, + connection: { connected: true, publicKey: null, origin: "http://evil.example.com" }, + }); + + expect(high.riskLevel).toBe("high"); + expect(auditWalletSecurity(makeAdapter(), secureOptions()).riskLevel).toBe("low"); + }); +}); diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 5629773..fbfcfce 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -443,6 +443,22 @@ export async function compareFeeAcrossNetworks( // It can be accessed via client.soroban.simulate(). +export { SpendingPolicyEngine, createSpendingPolicyEngine } from "./spendingPolicy"; +export type { + SpendingLimitPeriod, + SpendingLimit, + DestinationRestriction, + ApprovalThreshold, + SpendingPolicyConfig, + SpendingRequest, + SpendingRecordStatus, + SpendingRecord, + PolicyViolationCode, + PolicyViolation, + SpendingDecision, + SpendingEvaluation, + SpendingUsage, +} from "./spendingPolicy"; // ─── Historical fee forecasting (#523) ──────────────────────────────────────── export { forecastFees, diff --git a/src/transaction/spendingPolicy.ts b/src/transaction/spendingPolicy.ts new file mode 100644 index 0000000..e604f2c --- /dev/null +++ b/src/transaction/spendingPolicy.ts @@ -0,0 +1,547 @@ +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +// ─── Types ─── + +/** Window over which a cumulative limit accrues. */ +export type SpendingLimitPeriod = "per_transaction" | "daily" | "monthly"; + +/** + * A single configured limit. + * + * `asset` is the canonical asset identifier ("native" or "CODE:ISSUER"). A limit + * configured for one asset never constrains spending of another. + */ +export interface SpendingLimit { + asset: string; + amount: string; + period: SpendingLimitPeriod; +} + +/** + * A destination restriction applied to every evaluated transaction. + * + * When `mode` is "allow", only the listed destinations may receive funds. When + * "deny", the listed destinations are rejected and all others are permitted. + */ +export interface DestinationRestriction { + mode: "allow" | "deny"; + destinations: readonly string[]; +} + +/** + * Threshold above which a transaction requires explicit approval rather than + * being rejected outright. + */ +export interface ApprovalThreshold { + asset: string; + amount: string; + /** Number of distinct approvers that must approve. Defaults to 1. */ + requiredApprovers?: number; + /** Approvers permitted to act. When omitted, any approver identity is accepted. */ + approvers?: readonly string[]; +} + +export interface SpendingPolicyConfig { + limits?: readonly SpendingLimit[]; + destinationRestriction?: DestinationRestriction; + approvalThresholds?: readonly ApprovalThreshold[]; +} + +/** A transaction presented to the engine for evaluation. */ +export interface SpendingRequest { + id: string; + asset: string; + amount: string; + destination?: string; + /** Epoch milliseconds the request was made. Defaults to `Date.now()`. */ + timestamp?: number; +} + +/** + * Lifecycle of an evaluated request. + * + * Only `authorized`, `pending_approval` and `completed` records consume limit + * capacity — see {@link SpendingPolicyEngine.getSpendingUsage}. `rejected` and + * `failed` records release the capacity they had reserved. + */ +export type SpendingRecordStatus = + | "authorized" + | "pending_approval" + | "completed" + | "failed" + | "rejected"; + +export interface SpendingRecord { + id: string; + asset: string; + amount: string; + destination?: string; + timestamp: number; + status: SpendingRecordStatus; + approvals: readonly string[]; + requiredApprovers: number; +} + +export type PolicyViolationCode = + | "PER_TRANSACTION_LIMIT_EXCEEDED" + | "DAILY_LIMIT_EXCEEDED" + | "MONTHLY_LIMIT_EXCEEDED" + | "DESTINATION_NOT_ALLOWED" + | "DESTINATION_DENIED"; + +/** Structured description of a single failed policy rule. */ +export interface PolicyViolation { + code: PolicyViolationCode; + asset: string; + /** Configured ceiling for the rule that failed. Absent for destination rules. */ + limit?: string; + /** Spend already consuming the window at evaluation time. */ + used?: string; + /** Amount the request asked for. */ + requested?: string; + message: string; +} + +export type SpendingDecision = "allowed" | "requires_approval" | "denied"; + +export interface SpendingEvaluation { + decision: SpendingDecision; + requestId: string; + violations: readonly PolicyViolation[]; + /** Present when `decision` is "requires_approval". */ + requiredApprovers?: number; +} + +export interface SpendingUsage { + asset: string; + perTransaction: string; + daily: string; + monthly: string; +} + +// ─── Amount helpers ─── +// +// Amounts are decimal strings. They are compared as scaled BigInts so that +// values beyond IEEE-754 safe range keep full precision. + +const SCALE = 7; +const SCALE_FACTOR = 10n ** BigInt(SCALE); +const AMOUNT_PATTERN = /^\d+(\.\d+)?$/; + +function parseAmount(value: string): bigint | undefined { + const trimmed = value.trim(); + if (!AMOUNT_PATTERN.test(trimmed)) return undefined; + const [whole = "0", fraction = ""] = trimmed.split("."); + if (fraction.length > SCALE) return undefined; + return BigInt(whole) * SCALE_FACTOR + BigInt(fraction.padEnd(SCALE, "0") || "0"); +} + +function formatAmount(value: bigint): string { + const whole = value / SCALE_FACTOR; + const fraction = (value % SCALE_FACTOR).toString().padStart(SCALE, "0").replace(/0+$/, ""); + return fraction ? `${whole}.${fraction}` : `${whole}`; +} + +// ─── Window helpers ─── + +function startOfUtcDay(timestamp: number): number { + const date = new Date(timestamp); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +function startOfUtcMonth(timestamp: number): number { + const date = new Date(timestamp); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); +} + +/** + * Records that consume limit capacity. + * + * Pending and authorized requests are counted so that concurrent evaluations + * cannot each independently fit under a shared ceiling. + */ +function consumesCapacity(status: SpendingRecordStatus): boolean { + return status === "authorized" || status === "pending_approval" || status === "completed"; +} + +// ─── Engine ─── + +/** + * Evaluates transactions against configured spending limits before signing or + * submission. + * + * The engine is an in-memory ledger of decisions this SDK instance has made. It + * does not observe on-chain activity: spending performed outside the engine is + * invisible to it, so limits constrain the application, not the account itself. + */ +export class SpendingPolicyEngine { + private readonly limits = new Map(); + private readonly approvalThresholds = new Map(); + private readonly records = new Map(); + private destinationRestriction: DestinationRestriction | undefined; + + constructor(config?: SpendingPolicyConfig) { + for (const limit of config?.limits ?? []) { + this.setSpendingLimit(limit.asset, limit.amount, limit.period); + } + for (const threshold of config?.approvalThresholds ?? []) { + this.setApprovalThreshold(threshold); + } + if (config?.destinationRestriction) { + this.destinationRestriction = config.destinationRestriction; + } + } + + /** + * Configure a limit for an asset over a period. + * + * Re-configuring the same (asset, period) pair replaces the previous ceiling. + * Historical records are retained, so lowering a limit can leave the current + * window already over capacity. + */ + setSpendingLimit( + asset: string, + amount: string, + period: SpendingLimitPeriod, + ): SorokitResult { + if (!asset.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "setSpendingLimit: asset is required."); + } + const parsed = parseAmount(amount); + if (parsed === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `setSpendingLimit: amount "${amount}" is not a non-negative decimal with at most ${SCALE} places.`, + ); + } + const limit: SpendingLimit = { asset, amount: formatAmount(parsed), period }; + this.limits.set(`${asset}:${period}`, limit); + return ok(limit); + } + + /** Remove a previously configured limit. Returns true when one was removed. */ + removeSpendingLimit(asset: string, period: SpendingLimitPeriod): boolean { + return this.limits.delete(`${asset}:${period}`); + } + + /** List all configured limits. */ + listSpendingLimits(): SpendingLimit[] { + return [...this.limits.values()]; + } + + /** Restrict which destinations may receive funds. Pass `undefined` to clear. */ + setDestinationRestriction(restriction: DestinationRestriction | undefined): void { + this.destinationRestriction = restriction; + } + + /** + * Configure the amount above which an asset's transactions require approval. + */ + setApprovalThreshold(threshold: ApprovalThreshold): SorokitResult { + const parsed = parseAmount(threshold.amount); + if (parsed === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `setApprovalThreshold: amount "${threshold.amount}" is not a valid decimal amount.`, + ); + } + if (threshold.requiredApprovers !== undefined && threshold.requiredApprovers < 1) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "setApprovalThreshold: requiredApprovers must be >= 1.", + ); + } + this.approvalThresholds.set(threshold.asset, threshold); + return ok(threshold); + } + + /** + * Spend currently consuming each window for an asset. + * + * `perTransaction` reports the largest single capacity-consuming request, not + * a sum, because that limit applies to each transaction individually. + */ + getSpendingUsage(asset: string, now: number = Date.now()): SpendingUsage { + const dayStart = startOfUtcDay(now); + const monthStart = startOfUtcMonth(now); + let daily = 0n; + let monthly = 0n; + let largest = 0n; + + for (const record of this.records.values()) { + if (record.asset !== asset || !consumesCapacity(record.status)) continue; + const amount = parseAmount(record.amount) ?? 0n; + if (amount > largest) largest = amount; + if (record.timestamp >= monthStart) monthly += amount; + if (record.timestamp >= dayStart) daily += amount; + } + + return { + asset, + perTransaction: formatAmount(largest), + daily: formatAmount(daily), + monthly: formatAmount(monthly), + }; + } + + /** + * Evaluate a transaction against every configured rule. + * + * On an "allowed" or "requires_approval" decision the request is recorded and + * immediately reserves capacity, so a second concurrent evaluation sees the + * first one's spend. Release it with {@link SpendingPolicyEngine.rejectRequest} + * or {@link SpendingPolicyEngine.markFailed} if the transaction never reaches + * the network. + * + * @returns `ok(SpendingEvaluation)`, or an error when the request is malformed. + */ + evaluate(request: SpendingRequest): SorokitResult { + if (!request.id.trim()) { + return err(SorokitErrorCode.INVALID_CONFIG, "evaluate: request id is required."); + } + if (this.records.has(request.id)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `evaluate: request "${request.id}" has already been evaluated.`, + ); + } + const amount = parseAmount(request.amount); + if (amount === undefined) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `evaluate: amount "${request.amount}" is not a valid decimal amount.`, + ); + } + + const timestamp = request.timestamp ?? Date.now(); + const violations: PolicyViolation[] = []; + + const destinationViolation = this.checkDestination(request); + if (destinationViolation) violations.push(destinationViolation); + + violations.push(...this.checkLimits(request.asset, amount, timestamp)); + + if (violations.length > 0) { + this.records.set(request.id, { + id: request.id, + asset: request.asset, + amount: formatAmount(amount), + ...(request.destination !== undefined ? { destination: request.destination } : {}), + timestamp, + status: "rejected", + approvals: [], + requiredApprovers: 0, + }); + return ok({ decision: "denied", requestId: request.id, violations }); + } + + const requiredApprovers = this.requiredApproversFor(request.asset, amount); + const status: SpendingRecordStatus = requiredApprovers > 0 ? "pending_approval" : "authorized"; + + this.records.set(request.id, { + id: request.id, + asset: request.asset, + amount: formatAmount(amount), + ...(request.destination !== undefined ? { destination: request.destination } : {}), + timestamp, + status, + approvals: [], + requiredApprovers, + }); + + return ok({ + decision: requiredApprovers > 0 ? "requires_approval" : "allowed", + requestId: request.id, + violations: [], + ...(requiredApprovers > 0 ? { requiredApprovers } : {}), + }); + } + + private checkDestination(request: SpendingRequest): PolicyViolation | undefined { + const restriction = this.destinationRestriction; + if (!restriction || request.destination === undefined) return undefined; + const listed = restriction.destinations.includes(request.destination); + + if (restriction.mode === "allow" && !listed) { + return { + code: "DESTINATION_NOT_ALLOWED", + asset: request.asset, + message: `Destination ${request.destination} is not in the allow list.`, + }; + } + if (restriction.mode === "deny" && listed) { + return { + code: "DESTINATION_DENIED", + asset: request.asset, + message: `Destination ${request.destination} is explicitly denied.`, + }; + } + return undefined; + } + + private checkLimits(asset: string, amount: bigint, timestamp: number): PolicyViolation[] { + const violations: PolicyViolation[] = []; + const usage = this.getSpendingUsage(asset, timestamp); + + const perTransaction = this.limits.get(`${asset}:per_transaction`); + if (perTransaction) { + const ceiling = parseAmount(perTransaction.amount) ?? 0n; + if (amount > ceiling) { + violations.push({ + code: "PER_TRANSACTION_LIMIT_EXCEEDED", + asset, + limit: perTransaction.amount, + used: "0", + requested: formatAmount(amount), + message: `Transaction amount ${formatAmount(amount)} exceeds the per-transaction limit of ${perTransaction.amount}.`, + }); + } + } + + const daily = this.limits.get(`${asset}:daily`); + if (daily) { + const ceiling = parseAmount(daily.amount) ?? 0n; + const used = parseAmount(usage.daily) ?? 0n; + if (used + amount > ceiling) { + violations.push({ + code: "DAILY_LIMIT_EXCEEDED", + asset, + limit: daily.amount, + used: usage.daily, + requested: formatAmount(amount), + message: `Daily limit of ${daily.amount} exceeded — ${usage.daily} already authorized, ${formatAmount(amount)} requested.`, + }); + } + } + + const monthly = this.limits.get(`${asset}:monthly`); + if (monthly) { + const ceiling = parseAmount(monthly.amount) ?? 0n; + const used = parseAmount(usage.monthly) ?? 0n; + if (used + amount > ceiling) { + violations.push({ + code: "MONTHLY_LIMIT_EXCEEDED", + asset, + limit: monthly.amount, + used: usage.monthly, + requested: formatAmount(amount), + message: `Monthly limit of ${monthly.amount} exceeded — ${usage.monthly} already authorized, ${formatAmount(amount)} requested.`, + }); + } + } + + return violations; + } + + private requiredApproversFor(asset: string, amount: bigint): number { + const threshold = this.approvalThresholds.get(asset); + if (!threshold) return 0; + const ceiling = parseAmount(threshold.amount) ?? 0n; + if (amount <= ceiling) return 0; + return threshold.requiredApprovers ?? 1; + } + + /** + * Record an approval from `approver`. + * + * Duplicate approvals from the same identity are rejected so that one approver + * cannot satisfy a multi-approver requirement alone. The record becomes + * "authorized" once the required count is reached. + */ + approveRequest(requestId: string, approver: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `approveRequest: unknown request "${requestId}".`); + } + if (record.status !== "pending_approval") { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: request "${requestId}" is ${record.status}, not pending approval.`, + ); + } + if (record.approvals.includes(approver)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: ${approver} has already approved request "${requestId}".`, + ); + } + + const permitted = this.approvalThresholds.get(record.asset)?.approvers; + if (permitted && !permitted.includes(approver)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `approveRequest: ${approver} is not an authorized approver for ${record.asset}.`, + ); + } + + const approvals = [...record.approvals, approver]; + const updated: SpendingRecord = { + ...record, + approvals, + status: approvals.length >= record.requiredApprovers ? "authorized" : "pending_approval", + }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Reject a request, releasing the capacity it reserved. */ + rejectRequest(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `rejectRequest: unknown request "${requestId}".`); + } + const updated: SpendingRecord = { ...record, status: "rejected" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Mark an authorized request as submitted and confirmed. Capacity stays consumed. */ + markCompleted(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `markCompleted: unknown request "${requestId}".`); + } + if (record.status !== "authorized") { + return err( + SorokitErrorCode.INVALID_CONFIG, + `markCompleted: request "${requestId}" is ${record.status} — only authorized requests can complete.`, + ); + } + const updated: SpendingRecord = { ...record, status: "completed" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Mark a request as failed, releasing the capacity it reserved. */ + markFailed(requestId: string): SorokitResult { + const record = this.records.get(requestId); + if (!record) { + return err(SorokitErrorCode.INVALID_CONFIG, `markFailed: unknown request "${requestId}".`); + } + const updated: SpendingRecord = { ...record, status: "failed" }; + this.records.set(requestId, updated); + return ok(updated); + } + + /** Look up a single evaluated request. */ + getRequest(requestId: string): SpendingRecord | undefined { + return this.records.get(requestId); + } + + /** List evaluated requests, optionally filtered by status. */ + listRequests(status?: SpendingRecordStatus): SpendingRecord[] { + const all = [...this.records.values()]; + return status ? all.filter((record) => record.status === status) : all; + } + + /** Discard all recorded requests. Configured limits are retained. */ + reset(): void { + this.records.clear(); + } +} + +/** Construct a {@link SpendingPolicyEngine}. */ +export function createSpendingPolicyEngine(config?: SpendingPolicyConfig): SpendingPolicyEngine { + return new SpendingPolicyEngine(config); +} diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 41fe10a..2765775 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -651,3 +651,16 @@ export type { HardwareWalletDevice, HardwareWalletCapabilities, } from "./hardwareWallet"; + +export { auditWalletSecurity, isHighRiskConnection } from "./securityAudit"; +export type { + RiskSeverity, + RiskConfidence, + RiskFactor, + WalletVulnerability, + VulnerabilitySource, + WalletConnectionContext, + WalletSecurityAuditOptions, + RiskLevel, + WalletSecurityReport, +} from "./securityAudit"; diff --git a/src/wallet/securityAudit.ts b/src/wallet/securityAudit.ts new file mode 100644 index 0000000..eb99335 --- /dev/null +++ b/src/wallet/securityAudit.ts @@ -0,0 +1,458 @@ +import { getWalletCapabilities, WALLET_CAPABILITY_IDS } from "./capabilities"; +import type { WalletAdapter, WalletCapabilities, WalletCapabilityId, WalletType } from "./types"; + +// ─── Types ─── + +/** Severity of a single risk factor. */ +export type RiskSeverity = "info" | "low" | "medium" | "high" | "critical"; + +/** Confidence in the evidence behind a factor. */ +export type RiskConfidence = "confirmed" | "reported" | "unknown"; + +/** + * One contributing element of an assessment. + * + * `scoreDelta` is the penalty this factor applied to the score. A factor with a + * delta of 0 is informational and did not affect the score. + */ +export interface RiskFactor { + id: string; + severity: RiskSeverity; + confidence: RiskConfidence; + /** What was observed. */ + summary: string; + /** Penalty applied to the base score. Always <= 0. */ + scoreDelta: number; +} + +/** A known vulnerability affecting a wallet, supplied by the caller. */ +export interface WalletVulnerability { + id: string; + walletType: WalletType; + severity: RiskSeverity; + summary: string; + /** Adapter versions affected. When omitted, all versions are treated as affected. */ + affectedVersions?: readonly string[]; +} + +/** + * A source of vulnerability information. + * + * `knownWallets` declares which wallets the source has actually been checked + * against. A wallet absent from that list is reported as unknown rather than + * clean — see {@link auditWalletSecurity}. + */ +export interface VulnerabilitySource { + name: string; + vulnerabilities: readonly WalletVulnerability[]; + knownWallets?: readonly WalletType[]; + /** Epoch milliseconds the source data was produced. */ + updatedAt?: number; +} + +/** Details of the live connection being assessed. */ +export interface WalletConnectionContext { + connected?: boolean; + publicKey?: string | null; + /** Page origin the connection was established from, when observable. */ + origin?: string; + /** Adapter version string, when the adapter reports one. */ + adapterVersion?: string; +} + +export interface WalletSecurityAuditOptions { + connection?: WalletConnectionContext; + vulnerabilitySource?: VulnerabilitySource; + /** Epoch milliseconds treated as now, for staleness checks. */ + now?: number; + /** Age beyond which a vulnerability source is considered stale. Defaults to 30 days. */ + maxSourceAgeMs?: number; +} + +export type RiskLevel = "low" | "moderate" | "elevated" | "high"; + +export interface WalletSecurityReport { + walletType: WalletType; + /** + * Deterministic 0–100 rating derived from the factors below. + * + * This score summarizes only what was observable at audit time. It is not a + * guarantee of safety — read {@link WalletSecurityReport.factors}. + */ + score: number; + riskLevel: RiskLevel; + /** Every factor considered, including those that applied no penalty. */ + factors: readonly RiskFactor[]; + /** Human-readable warnings for factors needing attention. */ + warnings: readonly string[]; + /** Capabilities the adapter reported, as assessed. */ + capabilities: readonly { id: WalletCapabilityId; supported: boolean }[]; + /** True when vulnerability data for this wallet could not be established. */ + vulnerabilityDataAvailable: boolean; + /** Vulnerabilities matched against this wallet. */ + matchedVulnerabilities: readonly WalletVulnerability[]; +} + +// ─── Scoring constants ─── +// +// Penalties are fixed so that the same inputs always produce the same score. + +const BASE_SCORE = 100; + +const SEVERITY_PENALTY: Record = { + info: 0, + low: 5, + medium: 15, + high: 30, + critical: 50, +}; + +const DEFAULT_MAX_SOURCE_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +/** Capabilities whose absence weakens the security posture. */ +const SECURITY_RELEVANT_CAPABILITIES: readonly { + id: WalletCapabilityId; + severity: RiskSeverity; + label: string; +}[] = [ + { id: WALLET_CAPABILITY_IDS.transactionSign, severity: "critical", label: "transaction signing" }, + { + id: WALLET_CAPABILITY_IDS.transactionSignMultisig, + severity: "low", + label: "multi-signature signing", + }, + { + id: WALLET_CAPABILITY_IDS.transactionSignSoroban, + severity: "low", + label: "Soroban transaction signing", + }, +]; + +// ─── Origin evaluation ─── + +function isLocalHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +/** + * Assess the connection origin. + * + * Returns `undefined` when no origin was supplied — an unobservable origin is + * reported separately rather than silently passing. + */ +function evaluateOrigin(origin: string | undefined): RiskFactor | undefined { + if (origin === undefined) return undefined; + + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return { + id: "origin.unparseable", + severity: "medium", + confidence: "confirmed", + summary: `Connection origin "${origin}" is not a valid URL and could not be evaluated.`, + scoreDelta: -SEVERITY_PENALTY.medium, + }; + } + + if (parsed.protocol === "https:") { + return { + id: "origin.secure", + severity: "info", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} uses HTTPS.`, + scoreDelta: 0, + }; + } + + if (parsed.protocol === "http:" && isLocalHost(parsed.hostname)) { + return { + id: "origin.localhost", + severity: "low", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} is plain HTTP on localhost — acceptable for development, not production.`, + scoreDelta: -SEVERITY_PENALTY.low, + }; + } + + return { + id: "origin.insecure", + severity: "high", + confidence: "confirmed", + summary: `Connection origin ${parsed.origin} does not use HTTPS — traffic can be intercepted or modified.`, + scoreDelta: -SEVERITY_PENALTY.high, + }; +} + +// ─── Vulnerability matching ─── + +function versionAffected( + vulnerability: WalletVulnerability, + adapterVersion: string | undefined, +): boolean { + if (!vulnerability.affectedVersions || vulnerability.affectedVersions.length === 0) return true; + if (adapterVersion === undefined) return true; + return vulnerability.affectedVersions.includes(adapterVersion); +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} + +function toRiskLevel(score: number): RiskLevel { + if (score >= 85) return "low"; + if (score >= 65) return "moderate"; + if (score >= 40) return "elevated"; + return "high"; +} + +// ─── Audit ─── + +/** + * Produce a structured security assessment of a wallet adapter and connection. + * + * The report exposes every factor that produced the score so callers can act on + * the underlying evidence rather than the number alone. + * + * Important limits — the score is not a safety guarantee: + * - It reflects only what was observable at audit time: declared adapter + * capabilities, the supplied connection context, and whatever vulnerability + * data the caller passed in. + * - Capabilities are self-declared by the adapter and are not verified here. + * - With no `vulnerabilitySource`, or one that has not been checked against this + * wallet, vulnerability status is *unknown*. Unknown status is penalized, never + * treated as clean, and `vulnerabilityDataAvailable` is false. + * + * @param adapter - The wallet adapter to assess. + * @param options - Connection context and vulnerability source. + * @returns A structured report. This function does not throw. + */ +export function auditWalletSecurity( + adapter: WalletAdapter, + options: WalletSecurityAuditOptions = {}, +): WalletSecurityReport { + const factors: RiskFactor[] = []; + const now = options.now ?? Date.now(); + + let capabilities: WalletCapabilities; + try { + capabilities = getWalletCapabilities(adapter); + } catch { + capabilities = { + walletType: adapter.walletType, + capabilities: [], + supports: () => false, + }; + factors.push({ + id: "capabilities.unreadable", + severity: "medium", + confidence: "unknown", + summary: "Adapter capabilities could not be read; assessment proceeded without them.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } + + // ─── Availability ─── + let available: boolean; + try { + available = adapter.isAvailable(); + } catch { + available = false; + } + if (!available) { + factors.push({ + id: "adapter.unavailable", + severity: "medium", + confidence: "confirmed", + summary: "Wallet adapter reports it is not available in this environment.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } + + // ─── Capabilities ─── + for (const expected of SECURITY_RELEVANT_CAPABILITIES) { + const supported = capabilities.capabilities.some( + (capability) => capability.id === expected.id && capability.supported, + ); + if (supported) { + factors.push({ + id: `capability.${expected.id}.supported`, + severity: "info", + confidence: "confirmed", + summary: `Adapter declares support for ${expected.label}.`, + scoreDelta: 0, + }); + } else { + factors.push({ + id: `capability.${expected.id}.missing`, + severity: expected.severity, + confidence: "confirmed", + summary: `Adapter does not declare support for ${expected.label}.`, + scoreDelta: -SEVERITY_PENALTY[expected.severity], + }); + } + } + + // A capability answered from the fallback table was inferred, not reported. + const inferred = capabilities.capabilities.filter( + (capability) => capability.source === "fallback", + ); + if (inferred.length > 0) { + factors.push({ + id: "capabilities.inferred", + severity: "info", + confidence: "reported", + summary: `${inferred.length} capability value(s) were inferred from a static table rather than reported by the adapter.`, + scoreDelta: 0, + }); + } + + // ─── Connection state ─── + const connection = options.connection; + if (connection?.connected === true && !connection.publicKey) { + factors.push({ + id: "connection.unauthenticated", + severity: "high", + confidence: "confirmed", + summary: "Connection reports as connected but exposes no public key — authentication state is inconsistent.", + scoreDelta: -SEVERITY_PENALTY.high, + }); + } + + // ─── Origin ─── + const originFactor = evaluateOrigin(connection?.origin); + if (originFactor) { + factors.push(originFactor); + } else { + factors.push({ + id: "origin.unavailable", + severity: "info", + confidence: "unknown", + summary: "No connection origin was supplied, so origin could not be evaluated.", + scoreDelta: 0, + }); + } + + // ─── Vulnerabilities ─── + const source = options.vulnerabilitySource; + const matched: WalletVulnerability[] = []; + let vulnerabilityDataAvailable = false; + + if (!source) { + factors.push({ + id: "vulnerability.source.absent", + severity: "medium", + confidence: "unknown", + summary: + "No vulnerability source was configured. Vulnerability status is unknown and is not treated as clean.", + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } else { + const covered = + source.knownWallets === undefined || source.knownWallets.includes(adapter.walletType); + + if (!covered) { + factors.push({ + id: "vulnerability.wallet.uncovered", + severity: "medium", + confidence: "unknown", + summary: `Vulnerability source "${source.name}" has not been checked against ${adapter.walletType}; its status is unknown.`, + scoreDelta: -SEVERITY_PENALTY.medium, + }); + } else { + vulnerabilityDataAvailable = true; + + for (const vulnerability of source.vulnerabilities) { + if (vulnerability.walletType !== adapter.walletType) continue; + if (!versionAffected(vulnerability, connection?.adapterVersion)) continue; + matched.push(vulnerability); + factors.push({ + id: `vulnerability.${vulnerability.id}`, + severity: vulnerability.severity, + confidence: "reported", + summary: `Known vulnerability ${vulnerability.id}: ${vulnerability.summary}`, + scoreDelta: -SEVERITY_PENALTY[vulnerability.severity], + }); + } + + if (matched.length === 0) { + factors.push({ + id: "vulnerability.none.known", + severity: "info", + confidence: "reported", + summary: `No known vulnerabilities for ${adapter.walletType} in source "${source.name}". Absence of a report is not proof of safety.`, + scoreDelta: 0, + }); + } + + // An unversioned adapter cannot be excluded from version-scoped advisories. + if (connection?.adapterVersion === undefined && source.vulnerabilities.length > 0) { + factors.push({ + id: "vulnerability.version.unknown", + severity: "low", + confidence: "unknown", + summary: + "Adapter version is unknown, so version-scoped advisories were treated as applicable.", + scoreDelta: -SEVERITY_PENALTY.low, + }); + } + } + + const age = source.updatedAt === undefined ? undefined : now - source.updatedAt; + const maxAge = options.maxSourceAgeMs ?? DEFAULT_MAX_SOURCE_AGE_MS; + if (source.updatedAt === undefined) { + factors.push({ + id: "vulnerability.source.undated", + severity: "low", + confidence: "unknown", + summary: `Vulnerability source "${source.name}" carries no update timestamp; its freshness is unknown.`, + scoreDelta: -SEVERITY_PENALTY.low, + }); + } else if (age !== undefined && age > maxAge) { + factors.push({ + id: "vulnerability.source.stale", + severity: "low", + confidence: "confirmed", + summary: `Vulnerability source "${source.name}" is stale — last updated ${Math.floor(age / 86_400_000)} day(s) ago.`, + scoreDelta: -SEVERITY_PENALTY.low, + }); + } + } + + const score = clampScore( + factors.reduce((total, factor) => total + factor.scoreDelta, BASE_SCORE), + ); + + const warnings = factors + .filter((factor) => factor.severity !== "info") + .map((factor) => factor.summary); + + return { + walletType: adapter.walletType, + score, + riskLevel: toRiskLevel(score), + factors, + warnings, + capabilities: capabilities.capabilities.map((capability) => ({ + id: capability.id, + supported: capability.supported, + })), + vulnerabilityDataAvailable, + matchedVulnerabilities: matched, + }; +} + +/** + * Whether a report should block or warn a user before proceeding. + * + * True when the score falls into the elevated or high band, or any factor is + * high or critical severity. + */ +export function isHighRiskConnection(report: WalletSecurityReport): boolean { + return ( + report.riskLevel === "elevated" || + report.riskLevel === "high" || + report.factors.some((factor) => factor.severity === "high" || factor.severity === "critical") + ); +}