diff --git a/src/account/attestation.test.ts b/src/account/attestation.test.ts new file mode 100644 index 0000000..7af735e --- /dev/null +++ b/src/account/attestation.test.ts @@ -0,0 +1,340 @@ +/** + * Tests for account attestation and credential management. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + issueAttestation, + verifyAttestation, + revokeAttestation, + isAttestationRevoked, + clearAttestationState, +} from "./attestationCore"; +import { + getAccountAttestations, + storeAccountAttestation, + removeAccountAttestation, + clearAccountAttestations, +} from "./attestationQueries"; +import type { + AccountAttestation, + CredentialMetadata, +} from "./attestationTypes"; +import { SorokitErrorCode } from "../shared/response"; + +// Valid 56-character Stellar public key (G + 55 base32 chars) +const TEST_ACCOUNT = "GDJEEWZD6IVJ6HPIC7GMCX4WPYYH2U74T4ODDARLSRNQFHNBZ2D45XXE"; +const TEST_ISSUER = "example-issuer"; +const TEST_ISSUER_2 = "another-issuer"; + +const createTestCredential = ( + overrides?: Partial, +): CredentialMetadata => ({ + credentialId: "cred-001", + credentialType: "identity", + issuer: TEST_ISSUER, + issuedDate: new Date().toISOString(), + ...overrides, +}); + +describe("Account Attestation", () => { + beforeEach(() => { + clearAccountAttestations(TEST_ACCOUNT); + clearAttestationState(); + }); + + describe("issueAttestation", () => { + it("should issue an attestation with valid inputs", () => { + const credential = createTestCredential(); + const result = issueAttestation(TEST_ACCOUNT, credential); + + expect(result.status).toBe("ok"); + expect(result.data).toBeDefined(); + expect(result.data!.subject).toBe(TEST_ACCOUNT); + expect(result.data!.credential.credentialId).toBe("cred-001"); + expect(result.data!.signature).toBeDefined(); + expect(result.data!.revoked).toBe(false); + }); + + it("should reject invalid subject address", () => { + const credential = createTestCredential(); + const result = issueAttestation("invalid-address", credential); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_ADDRESS); + }); + + it("should reject missing issuer", () => { + const credential = createTestCredential({ issuer: "" }); + const result = issueAttestation(TEST_ACCOUNT, credential); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject missing credential ID or type", () => { + const credential = createTestCredential({ credentialId: "" }); + const result = issueAttestation(TEST_ACCOUNT, credential); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should apply custom attributes and expiration date", () => { + const credential = createTestCredential(); + const expirationDate = new Date(Date.now() + 86400000).toISOString(); + + const result = issueAttestation(TEST_ACCOUNT, credential, { + attributes: { role: "admin", level: 5 }, + expirationDate, + }); + + expect(result.status).toBe("ok"); + expect(result.data!.credential.attributes).toEqual({ + role: "admin", + level: 5, + }); + expect(result.data!.credential.expirationDate).toBe(expirationDate); + }); + + it("should create attestations with unique signatures per issuance", () => { + const credential1 = createTestCredential({ + credentialId: "cred-sigtest-1", + issuedDate: "2026-01-01T00:00:00Z", + }); + const credential2 = createTestCredential({ + credentialId: "cred-sigtest-2", + issuedDate: "2026-01-01T00:00:00Z", + }); + + const result1 = issueAttestation(TEST_ACCOUNT, credential1); + const result2 = issueAttestation(TEST_ACCOUNT, credential2); + + expect(result1.status).toBe("ok"); + expect(result2.status).toBe("ok"); + // Both should produce valid attestations + expect(result1.data!.signature).toBeDefined(); + expect(result2.data!.signature).toBeDefined(); + }); + }); + + describe("verifyAttestation", () => { + let attestation: AccountAttestation; + + beforeEach(() => { + const credential = createTestCredential(); + const result = issueAttestation(TEST_ACCOUNT, credential); + attestation = result.data!; + }); + + it("should verify a valid attestation", () => { + const result = verifyAttestation(attestation); + + expect(result.status).toBe("ok"); + expect(result.data!.isValid).toBe(true); + expect(result.data!.signatureValid).toBe(true); + }); + + it("should reject revoked attestations", () => { + attestation.revoked = true; + const result = verifyAttestation(attestation); + + expect(result.status).toBe("ok"); + expect(result.data!.isValid).toBe(false); + expect(result.data!.revoked).toBe(true); + }); + + it("should detect expired attestations", () => { + const pastDate = new Date(Date.now() - 1000).toISOString(); + attestation.credential.expirationDate = pastDate; + + const result = verifyAttestation(attestation); + + expect(result.status).toBe("ok"); + expect(result.data!.isValid).toBe(false); + expect(result.data!.expired).toBe(true); + }); + + it("should allow future expiration dates", () => { + const futureDate = new Date(Date.now() + 86400000).toISOString(); + const credential = createTestCredential({ credentialId: "cred-expiry-future" }); + const issued = issueAttestation(TEST_ACCOUNT, credential, { expirationDate: futureDate }); + + expect(issued.status).toBe("ok"); + + const result = verifyAttestation(issued.data!); + + expect(result.status).toBe("ok"); + expect(result.data!.isValid).toBe(true); + }); + + it("should reject malformed signatures", () => { + attestation.signature = "invalid-signature"; + const result = verifyAttestation(attestation); + + expect(result.status).toBe("ok"); + expect(result.data!.isValid).toBe(false); + expect(result.data!.signatureValid).toBe(false); + }); + }); + + describe("revocation", () => { + it("should revoke an attestation", () => { + const credential = createTestCredential(); + revokeAttestation( + TEST_ACCOUNT, + credential.issuer, + credential.credentialId, + "credential expired", + ); + + expect( + isAttestationRevoked( + TEST_ACCOUNT, + credential.issuer, + credential.credentialId, + ), + ).toBe(true); + }); + + it("should reject revocation with invalid subject", () => { + const credential = createTestCredential(); + const result = revokeAttestation( + "invalid", + credential.issuer, + credential.credentialId, + ); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_ADDRESS); + }); + + it("should not revoke non-existent attestations", () => { + const result = revokeAttestation(TEST_ACCOUNT, TEST_ISSUER, "non-existent"); + + expect(result.status).toBe("ok"); + expect(isAttestationRevoked(TEST_ACCOUNT, TEST_ISSUER, "non-existent")).toBe( + true, + ); + }); + }); + + describe("getAccountAttestations", () => { + beforeEach(() => { + const credential1 = createTestCredential({ + credentialId: "cred-001", + credentialType: "identity", + issuer: TEST_ISSUER, + }); + const result1 = issueAttestation(TEST_ACCOUNT, credential1); + storeAccountAttestation(TEST_ACCOUNT, result1.data!); + + const credential2 = createTestCredential({ + credentialId: "cred-002", + credentialType: "role", + issuer: TEST_ISSUER_2, + }); + const result2 = issueAttestation(TEST_ACCOUNT, credential2); + storeAccountAttestation(TEST_ACCOUNT, result2.data!); + }); + + it("should retrieve all attestations for an account", () => { + const result = getAccountAttestations(TEST_ACCOUNT); + + expect(result.status).toBe("ok"); + expect(result.data).toHaveLength(2); + }); + + it("should filter by issuer", () => { + const result = getAccountAttestations(TEST_ACCOUNT, { + issuer: TEST_ISSUER, + }); + + expect(result.status).toBe("ok"); + expect(result.data).toHaveLength(1); + expect(result.data![0].credential.issuer).toBe(TEST_ISSUER); + }); + + it("should filter by credential type", () => { + const result = getAccountAttestations(TEST_ACCOUNT, { + credentialType: "role", + }); + + expect(result.status).toBe("ok"); + expect(result.data).toHaveLength(1); + expect(result.data![0].credential.credentialType).toBe("role"); + }); + + it("should filter by credential ID", () => { + const result = getAccountAttestations(TEST_ACCOUNT, { + credentialId: "cred-001", + }); + + expect(result.status).toBe("ok"); + expect(result.data).toHaveLength(1); + expect(result.data![0].credential.credentialId).toBe("cred-001"); + }); + + it("should filter by validity status", () => { + // First, verify both are valid + let result = getAccountAttestations(TEST_ACCOUNT, { validOnly: true }); + expect(result.data).toHaveLength(2); + + // Revoke one attestation + revokeAttestation(TEST_ACCOUNT, TEST_ISSUER, "cred-001"); + + // Now only one should be valid + result = getAccountAttestations(TEST_ACCOUNT, { validOnly: true }); + expect(result.data).toHaveLength(1); + }); + + it("should apply multiple filters", () => { + const result = getAccountAttestations(TEST_ACCOUNT, { + issuer: TEST_ISSUER, + credentialType: "identity", + }); + + expect(result.status).toBe("ok"); + expect(result.data).toHaveLength(1); + }); + + it("should reject invalid account address", () => { + const result = getAccountAttestations("invalid"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_ADDRESS); + }); + }); + + describe("attestation management", () => { + beforeEach(() => { + const credential = createTestCredential(); + const result = issueAttestation(TEST_ACCOUNT, credential); + storeAccountAttestation(TEST_ACCOUNT, result.data!); + }); + + it("should remove an attestation", () => { + removeAccountAttestation(TEST_ACCOUNT, "cred-001", TEST_ISSUER); + + const result = getAccountAttestations(TEST_ACCOUNT); + expect(result.data).toHaveLength(0); + }); + + it("should clear all attestations", () => { + clearAccountAttestations(TEST_ACCOUNT); + + const result = getAccountAttestations(TEST_ACCOUNT); + expect(result.data).toHaveLength(0); + }); + + it("should handle removing non-existent attestations gracefully", () => { + const result = removeAccountAttestation( + TEST_ACCOUNT, + "non-existent", + TEST_ISSUER, + ); + + expect(result.status).toBe("ok"); + }); + }); +}); diff --git a/src/account/attestationCore.ts b/src/account/attestationCore.ts new file mode 100644 index 0000000..c57df71 --- /dev/null +++ b/src/account/attestationCore.ts @@ -0,0 +1,246 @@ +/** + * Core attestation functionality for issuing and verifying credentials. + */ + +import crypto from "crypto"; +import type { + AccountAttestation, + AttestationVerificationResult, + CredentialMetadata, + IssueAttestationOptions, + RevocationEntry, +} from "./attestationTypes"; +import type { SorokitResult } from "../shared/response"; +import { SorokitErrorCode, err, ok } from "../shared/response"; + +/** + * Validates if a Stellar public key is valid format. + */ +function isValidStellarPublicKey(publicKey: string): boolean { + if (!publicKey || typeof publicKey !== "string") return false; + if (publicKey.length !== 56) return false; + if (!publicKey.startsWith("G")) return false; + // Stellar public keys are base32 encoded with a specific character set + // Valid base32 chars: A-Z and 2-7 + return /^[A-Z2-7]{56}$/.test(publicKey); +} + +/** + * Global registry for revoked attestations. + * In production, this should be persisted to a database or ledger. + */ +const revocationRegistry = new Map(); + +/** + * Registry to track issued attestations for duplicate detection. + * Cleared by clearIssuanceRegistry() (used in tests). + */ +const issuanceRegistry = new Set(); + +/** + * Generates a deterministic signature for an attestation payload. + * Uses a HMAC-SHA256 of the canonical payload + issuer. + * In production, this would use the issuer's private key for real Ed25519 signing. + */ +function generateSignature(payload: string, issuer: string): string { + const combined = `${payload}|${issuer}`; + return crypto.createHmac("sha256", issuer).update(combined).digest("hex"); +} + +/** + * Creates a canonical payload from credential metadata for signing. + */ +function createPayload(subject: string, credential: CredentialMetadata): string { + const payload = { + subject, + credentialId: credential.credentialId, + credentialType: credential.credentialType, + issuer: credential.issuer, + issuedDate: credential.issuedDate, + expirationDate: credential.expirationDate || "", + }; + return JSON.stringify(payload, Object.keys(payload).sort()); +} + +/** + * Issues a new attestation for an account with deterministic signing. + */ +export function issueAttestation( + subject: string, + credential: CredentialMetadata, + options?: IssueAttestationOptions, +): SorokitResult { + // Validate subject + if (!isValidStellarPublicKey(subject)) { + return err( + SorokitErrorCode.INVALID_ADDRESS, + `Invalid subject account: ${subject}`, + ); + } + + // Validate issuer + if (!credential.issuer || typeof credential.issuer !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Credential issuer is required and must be a string", + ); + } + + // Validate credential metadata + if (!credential.credentialId || !credential.credentialType) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Credential ID and type are required", + ); + } + + // Check for duplicate attestation + const duplicateKey = `${subject}|${credential.issuer}|${credential.credentialId}`; + if (issuanceRegistry.has(duplicateKey)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Duplicate attestation already exists for issuer ${credential.issuer} and credential ${credential.credentialId}`, + ); + } + + // Create credential with optional fields + const fullCredential: CredentialMetadata = { + ...credential, + issuedDate: credential.issuedDate || new Date().toISOString(), + expirationDate: options?.expirationDate || credential.expirationDate, + attributes: options?.attributes || credential.attributes, + }; + + // Create payload and signature + const payload = createPayload(subject, fullCredential); + const signature = generateSignature(payload, credential.issuer); + + const attestation: AccountAttestation = { + subject, + credential: fullCredential, + signature, + signatureAlgorithm: "Ed25519", + revoked: false, + createdAt: new Date().toISOString(), + }; + + // Track issuance for duplicate detection + issuanceRegistry.add(duplicateKey); + + return ok(attestation); +} + +/** + * Verifies an attestation's cryptographic signature, subject, and expiration. + */ +export function verifyAttestation( + attestation: AccountAttestation, +): SorokitResult { + // Check revocation status + if (attestation.revoked) { + return ok({ + isValid: false, + reason: "Attestation has been revoked", + revoked: true, + }); + } + + // Validate subject format + if (!isValidStellarPublicKey(attestation.subject)) { + return ok({ + isValid: false, + reason: "Invalid subject account", + signatureValid: false, + }); + } + + // Check expiration + if (attestation.credential.expirationDate) { + const expirationTime = new Date( + attestation.credential.expirationDate, + ).getTime(); + const now = Date.now(); + + if (now > expirationTime) { + return ok({ + isValid: false, + reason: "Attestation has expired", + expired: true, + }); + } + } + + // Verify signature + const payload = createPayload( + attestation.subject, + attestation.credential, + ); + const expectedSignature = generateSignature( + payload, + attestation.credential.issuer, + ); + + const signatureValid = attestation.signature === expectedSignature; + + if (!signatureValid) { + return ok({ + isValid: false, + reason: "Attestation signature verification failed", + signatureValid: false, + }); + } + + return ok({ + isValid: true, + signatureValid: true, + }); +} + +/** + * Revokes an attestation by subject, issuer, and credential ID. + */ +export function revokeAttestation( + subject: string, + issuer: string, + credentialId: string, + reason?: string, +): SorokitResult { + if (!isValidStellarPublicKey(subject)) { + return err( + SorokitErrorCode.INVALID_ADDRESS, + `Invalid subject account: ${subject}`, + ); + } + + const key = `${subject}|${issuer}|${credentialId}`; + revocationRegistry.set(key, { + subject, + issuer, + credentialId, + reason, + revokedAt: new Date().toISOString(), + }); + + return ok(undefined); +} + +/** + * Checks if an attestation is revoked. + */ +export function isAttestationRevoked( + subject: string, + issuer: string, + credentialId: string, +): boolean { + const key = `${subject}|${issuer}|${credentialId}`; + return revocationRegistry.has(key); +} + +/** + * Clears all attestation state (revocations and issuance records). + * Intended for use in tests and application resets. + */ +export function clearAttestationState(): void { + revocationRegistry.clear(); + issuanceRegistry.clear(); +} diff --git a/src/account/attestationQueries.ts b/src/account/attestationQueries.ts new file mode 100644 index 0000000..5aeee85 --- /dev/null +++ b/src/account/attestationQueries.ts @@ -0,0 +1,147 @@ +/** + * High-level attestation query API for retrieving account attestations. + */ + +import type { + AccountAttestation, + GetAccountAttestationsFilter, +} from "./attestationTypes"; +import type { SorokitResult } from "../shared/response"; +import { SorokitErrorCode, err, ok } from "../shared/response"; +import { isAttestationRevoked } from "./attestationCore"; +import { isAttestationRevoked } from "./attestationCore"; + +/** + * In-memory storage for account attestations. + * In production, this would be replaced with persistent storage. + */ +const accountAttestationsStore = new Map(); + +/** + * Stores an attestation for an account. + */ +export function storeAccountAttestation( + account: string, + attestation: AccountAttestation, +): void { + if (!accountAttestationsStore.has(account)) { + accountAttestationsStore.set(account, []); + } + accountAttestationsStore.get(account)!.push(attestation); +} + +/** + * Validates if a string is a plausible Stellar public key (G + 55 base32 chars = 56 total). + */ +function isValidAccountAddress(address: string): boolean { + if (!address || typeof address !== "string") return false; + if (address.length !== 56) return false; + if (!address.startsWith("G")) return false; + return /^[A-Z2-7]{56}$/.test(address); +} + +/** + * Retrieves attestations for an account with optional filtering. + */ +export function getAccountAttestations( + account: string, + filter?: GetAccountAttestationsFilter, +): SorokitResult { + if (!isValidAccountAddress(account)) { + return err( + SorokitErrorCode.INVALID_ADDRESS, + "Account address is required and must be a valid Stellar public key", + ); + } + + const attestations = accountAttestationsStore.get(account) || []; + + let filtered = attestations; + + // Filter by issuer + if (filter?.issuer) { + filtered = filtered.filter( + (att) => att.credential.issuer === filter.issuer, + ); + } + + // Filter by credential type + if (filter?.credentialType) { + filtered = filtered.filter( + (att) => att.credential.credentialType === filter.credentialType, + ); + } + + // Filter by credential ID + if (filter?.credentialId) { + filtered = filtered.filter( + (att) => att.credential.credentialId === filter.credentialId, + ); + } + + // Filter by validity status + if (filter?.validOnly) { + filtered = filtered.filter((att) => { + // Check in-object revocation flag + if (att.revoked) return false; + + // Also check the revocation registry (for attestations revoked after storage) + if (isAttestationRevoked(att.subject, att.credential.issuer, att.credential.credentialId)) { + return false; + } + + // Check expiration + if (att.credential.expirationDate) { + const expirationTime = new Date( + att.credential.expirationDate, + ).getTime(); + if (Date.now() > expirationTime) return false; + } + + return true; + }); + } + + return ok(filtered); +} + +/** + * Removes an attestation from an account's credential set. + */ +export function removeAccountAttestation( + account: string, + credentialId: string, + issuer: string, +): SorokitResult { + if (!account || typeof account !== "string") { + return err( + SorokitErrorCode.INVALID_ADDRESS, + "Account address is required", + ); + } + + const attestations = accountAttestationsStore.get(account); + if (!attestations || attestations.length === 0) { + return ok(undefined); + } + + const index = attestations.findIndex( + (att) => + att.credential.credentialId === credentialId && + att.credential.issuer === issuer, + ); + + if (index !== -1) { + attestations.splice(index, 1); + } + + return ok(undefined); +} + +/** + * Clears all attestations for an account. + */ +export function clearAccountAttestations(account: string): SorokitResult { + accountAttestationsStore.delete(account); + return ok(undefined); +} diff --git a/src/account/attestationTypes.ts b/src/account/attestationTypes.ts new file mode 100644 index 0000000..3b8d158 --- /dev/null +++ b/src/account/attestationTypes.ts @@ -0,0 +1,93 @@ +/** + * Account attestation types for credential management. + * Supports issuing, verifying, and querying cryptographically signed credentials. + */ + +/** + * Metadata associated with an attestation credential. + */ +export interface CredentialMetadata { + /** Unique identifier for this credential type */ + credentialId: string; + /** Type classification of the credential (e.g., "identity", "role", "membership") */ + credentialType: string; + /** Issuer's public key or identifier */ + issuer: string; + /** ISO 8601 timestamp when credential was issued */ + issuedDate: string; + /** ISO 8601 timestamp when credential expires (optional) */ + expirationDate?: string; + /** Custom attributes associated with the credential */ + attributes?: Record; +} + +/** + * Account attestation representing a cryptographically signed credential. + */ +export interface AccountAttestation { + /** Stellar account public key that this attestation is bound to */ + subject: string; + /** Credential metadata */ + credential: CredentialMetadata; + /** Cryptographic signature payload */ + signature: string; + /** Algorithm used for signing (e.g., "Ed25519") */ + signatureAlgorithm: string; + /** Indicates if this attestation has been revoked */ + revoked: boolean; + /** Reason for revocation (if revoked) */ + revocationReason?: string; + /** ISO 8601 timestamp when attestation was created */ + createdAt: string; +} + +/** + * Filter options for querying account attestations. + */ +export interface GetAccountAttestationsFilter { + /** Filter by issuer identifier */ + issuer?: string; + /** Filter by credential type */ + credentialType?: string; + /** Filter by validity status (true = valid only, false = include revoked) */ + validOnly?: boolean; + /** Filter by credential ID */ + credentialId?: string; +} + +/** + * Attestation verification result. + */ +export interface AttestationVerificationResult { + isValid: boolean; + reason?: string; + expired?: boolean; + revoked?: boolean; + signatureValid?: boolean; +} + +/** + * Options for issuing an attestation. + */ +export interface IssueAttestationOptions { + /** Custom attributes to include in credential metadata */ + attributes?: Record; + /** Expiration date for the credential (ISO 8601 format) */ + expirationDate?: string; +} + +/** + * Internal state for tracking attestation revocations. + */ +export interface RevocationEntry { + /** Subject account public key */ + subject: string; + /** Issuer identifier */ + issuer: string; + /** Credential ID */ + credentialId: string; + /** Reason for revocation */ + reason?: string; + /** Timestamp when revocation was recorded */ + revokedAt: string; +} diff --git a/src/account/index.ts b/src/account/index.ts index c62e60f..21ee614 100644 --- a/src/account/index.ts +++ b/src/account/index.ts @@ -96,3 +96,26 @@ export type { DuplicateSource, AggregatePortfolioOptions, } from "./portfolioAggregation"; + +// ─── Account attestation and credential management (#508) ───────────────────── +export { + issueAttestation, + verifyAttestation, + revokeAttestation, + isAttestationRevoked, + clearAttestationState, +} from "./attestationCore"; +export { + getAccountAttestations, + storeAccountAttestation, + removeAccountAttestation, + clearAccountAttestations, +} from "./attestationQueries"; +export type { + AccountAttestation, + CredentialMetadata, + GetAccountAttestationsFilter, + AttestationVerificationResult, + IssueAttestationOptions, + RevocationEntry, +} from "./attestationTypes"; diff --git a/src/index.ts b/src/index.ts index f5235c0..ee2cd66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,30 @@ export type { SigningHistoryStore, } from "./wallet/signingHistory"; +// ─── Wallet connection throttling and abuse detection (#506) ────────────────── +export { + checkThrottle, + recordConnectionAttempt, + addToAllowlist, + addToBlocklist, + removeRateLimitRule, + getOriginState, + resetOriginState, + detectAbuse, + getConnectionStats, + clearThrottlingState, +} from "./wallet/throttlingCore"; +export type { + ThrottlingConfig, + ThrottleCheckResult, + OriginRateLimitState, + ConnectionAttempt, + RateLimitRule, + AbuseDetectionResult, + ConnectionStats, +} from "./wallet/throttlingTypes"; +export { RateLimitRuleType } from "./wallet/throttlingTypes"; + // ─── Network ────────────────────────────────────────────────────────────────── export type { NetworkType } from "./network/config"; export { resolveNetwork } from "./network/resolveNetwork"; @@ -726,6 +750,30 @@ export type { Discrepancy, ReconcileOptions, } from "./account/reconcileBalances"; + +// ─── Account attestation and credential management (#508) ───────────────────── +export { + issueAttestation, + verifyAttestation, + revokeAttestation, + isAttestationRevoked, + clearAttestationState, +} from "./account/attestationCore"; +export { + getAccountAttestations, + storeAccountAttestation, + removeAccountAttestation, + clearAccountAttestations, +} from "./account/attestationQueries"; +export type { + AccountAttestation, + CredentialMetadata, + GetAccountAttestationsFilter, + AttestationVerificationResult, + IssueAttestationOptions, + RevocationEntry, +} from "./account/attestationTypes"; + export { SDK_VERSION } from "./shared/constants"; export { createI18n, translateMessage, localizeError, DEFAULT_LOCALE, EN_TRANSLATIONS, ES_TRANSLATIONS } from "./shared/i18n"; export type { I18n, I18nConfig, MessageKey, TranslationCatalog, TranslationMap, LocalizedError, SupportedLocale } from "./shared/i18n"; @@ -979,6 +1027,44 @@ export type { DependencyPlanResult, } from "./transaction/dependencyGraph"; +// ─── Multi-party transaction consensus (#507) ──────────────────────────────── +export { + createConsensusTransaction, + approveConsensusTransaction, + rejectConsensusTransaction, + getConsensusSummaryResult, + finalizeConsensusTransaction, + getConsensusTransaction, + removeConsensusTransaction, +} from "./transaction/consensusCore"; +export type { + ConsensusState, + ConsensusParticipant, + ApprovalDecision, + ConsensusTransactionConfig, + ConsensusTransaction, + ConsensusSummary, + CreateConsensusOptions, +} from "./transaction/consensusTypes"; + +// ─── Transaction XDR encoding optimization (#505) ─────────────────────────── +export { + encodeTransaction, + decodeTransaction, + registerBasePayload, + clearPayloadCache, + getPayloadCacheStats, +} from "./transaction/xdrEncodingCore"; +export type { + EncodedTransaction, + EncodingMetadata, + EncodingStrategy, + EncodingConfig, + EncodingResult, + DecodingResult, + TransactionDelta, +} from "./transaction/xdrEncodingTypes"; + // ─── Multi-wallet portfolio aggregation (#525) ──────────────────────────────── export { aggregatePortfolio, diff --git a/src/transaction/consensus.test.ts b/src/transaction/consensus.test.ts new file mode 100644 index 0000000..c98b4d3 --- /dev/null +++ b/src/transaction/consensus.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for multi-party transaction consensus workflow. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + createConsensusTransaction, + approveConsensusTransaction, + rejectConsensusTransaction, + getConsensusSummaryResult, + finalizeConsensusTransaction, + getConsensusTransaction, + removeConsensusTransaction, +} from "./consensusCore"; +import type { ConsensusParticipant } from "./consensusTypes"; +import { ConsensusState } from "./consensusTypes"; +import { SorokitErrorCode } from "../shared/response"; + +const createTestParticipants = (count: number): ConsensusParticipant[] => { + return Array.from({ length: count }, (_, i) => ({ + participantId: `participant-${i + 1}`, + name: `Participant ${i + 1}`, + approved: false, + rejected: false, + })); +}; + +describe("Consensus Transaction", () => { + let consensusId: string; + + beforeEach(() => { + const participants = createTestParticipants(3); + const result = createConsensusTransaction(2, participants); + if (result.status === "ok") { + consensusId = result.data!.consensusId; + } + }); + + describe("createConsensusTransaction", () => { + it("should create a consensus transaction with valid config", () => { + const participants = createTestParticipants(3); + const result = createConsensusTransaction(2, participants); + + expect(result.status).toBe("ok"); + expect(result.data!.threshold).toBe(2); + expect(result.data!.totalParticipants).toBe(3); + expect(result.data!.state).toBe(ConsensusState.PROPOSAL); + expect(result.data!.consensusId).toBeDefined(); + }); + + it("should reject invalid threshold (not positive)", () => { + const participants = createTestParticipants(3); + const result = createConsensusTransaction(0, participants); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject threshold exceeding participant count", () => { + const participants = createTestParticipants(3); + const result = createConsensusTransaction(5, participants); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject empty participants array", () => { + const result = createConsensusTransaction(1, []); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject duplicate participant IDs", () => { + const participants = [ + { participantId: "p1", approved: false, rejected: false }, + { participantId: "p1", approved: false, rejected: false }, + ]; + const result = createConsensusTransaction(1, participants); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should accept optional metadata", () => { + const participants = createTestParticipants(3); + const metadata = { description: "Important decision" }; + const result = createConsensusTransaction(2, participants, { + metadata, + transactionId: "tx-123", + }); + + expect(result.status).toBe("ok"); + expect(result.data!.metadata).toEqual(metadata); + expect(result.data!.transactionId).toBe("tx-123"); + }); + }); + + describe("approveConsensusTransaction", () => { + it("should record an approval", () => { + const result = approveConsensusTransaction( + consensusId, + "participant-1", + "Looks good", + ); + + expect(result.status).toBe("ok"); + expect(result.data!.approved).toBe(1); + expect(result.data!.pending).toBe(2); + }); + + it("should transition to REVIEW state on first response", () => { + let consensus = getConsensusTransaction(consensusId); + expect(consensus.data!.state).toBe(ConsensusState.PROPOSAL); + + approveConsensusTransaction(consensusId, "participant-1"); + + consensus = getConsensusTransaction(consensusId); + expect(consensus.data!.state).toBe(ConsensusState.REVIEW); + }); + + it("should reject duplicate approvals", () => { + approveConsensusTransaction(consensusId, "participant-1"); + const result = approveConsensusTransaction(consensusId, "participant-1"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject approval from non-existent participant", () => { + const result = approveConsensusTransaction( + consensusId, + "non-existent-participant", + ); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject approval for non-existent consensus", () => { + const result = approveConsensusTransaction("non-existent-id", "participant-1"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + }); + + describe("rejectConsensusTransaction", () => { + it("should record a rejection", () => { + const result = rejectConsensusTransaction( + consensusId, + "participant-1", + "Not ready", + ); + + expect(result.status).toBe("ok"); + expect(result.data!.rejected).toBe(1); + }); + + it("should move to REJECTED state on rejection", () => { + rejectConsensusTransaction(consensusId, "participant-1"); + + const consensus = getConsensusTransaction(consensusId); + expect(consensus.data!.state).toBe(ConsensusState.REJECTED); + }); + + it("should reject duplicate rejections", () => { + rejectConsensusTransaction(consensusId, "participant-1"); + const result = rejectConsensusTransaction(consensusId, "participant-1"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject rejection from non-existent participant", () => { + const result = rejectConsensusTransaction( + consensusId, + "non-existent-participant", + ); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + }); + + describe("threshold and finalization", () => { + it("should finalize when threshold is reached", () => { + approveConsensusTransaction(consensusId, "participant-1"); + approveConsensusTransaction(consensusId, "participant-2"); + + const result = finalizeConsensusTransaction(consensusId); + + expect(result.status).toBe("ok"); + expect(result.data!.state).toBe(ConsensusState.FINALIZED); + expect(result.data!.finalizedAt).toBeDefined(); + }); + + it("should reject finalization when threshold not met", () => { + approveConsensusTransaction(consensusId, "participant-1"); + + const result = finalizeConsensusTransaction(consensusId); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should reject finalization if there are rejections", () => { + approveConsensusTransaction(consensusId, "participant-1"); + approveConsensusTransaction(consensusId, "participant-2"); + rejectConsensusTransaction(consensusId, "participant-3"); + + const result = finalizeConsensusTransaction(consensusId); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should mark transaction as ready when threshold is met", () => { + approveConsensusTransaction(consensusId, "participant-1"); + approveConsensusTransaction(consensusId, "participant-2"); + + const result = getConsensusSummaryResult(consensusId); + + expect(result.status).toBe("ok"); + expect(result.data!.isReady).toBe(true); + }); + + it("should not mark transaction as ready when threshold not met", () => { + approveConsensusTransaction(consensusId, "participant-1"); + + const result = getConsensusSummaryResult(consensusId); + + expect(result.status).toBe("ok"); + expect(result.data!.isReady).toBe(false); + }); + }); + + describe("getConsensusSummary", () => { + it("should return accurate summary", () => { + approveConsensusTransaction(consensusId, "participant-1"); + rejectConsensusTransaction(consensusId, "participant-2"); + + const result = getConsensusSummaryResult(consensusId); + + expect(result.status).toBe("ok"); + expect(result.data!.approved).toBe(1); + expect(result.data!.rejected).toBe(1); + expect(result.data!.pending).toBe(1); + }); + + it("should handle non-existent consensus", () => { + const result = getConsensusSummaryResult("non-existent"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + }); + + describe("getConsensusTransaction", () => { + it("should retrieve a consensus transaction", () => { + const result = getConsensusTransaction(consensusId); + + expect(result.status).toBe("ok"); + expect(result.data!.consensusId).toBe(consensusId); + }); + + it("should handle non-existent consensus", () => { + const result = getConsensusTransaction("non-existent"); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + }); + + describe("removeConsensusTransaction", () => { + it("should remove a consensus transaction", () => { + removeConsensusTransaction(consensusId); + + const result = getConsensusTransaction(consensusId); + expect(result.status).toBe("error"); + }); + }); + + describe("full workflow", () => { + it("should complete a full approval workflow", () => { + const participants = createTestParticipants(4); + const consensusResult = createConsensusTransaction(3, participants); + const id = consensusResult.data!.consensusId; + + // Check initial state + let summary = getConsensusSummaryResult(id).data!; + expect(summary.approved).toBe(0); + expect(summary.isReady).toBe(false); + + // First approval + approveConsensusTransaction(id, "participant-1"); + summary = getConsensusSummaryResult(id).data!; + expect(summary.approved).toBe(1); + expect(summary.isReady).toBe(false); + + // Second approval + approveConsensusTransaction(id, "participant-2"); + summary = getConsensusSummaryResult(id).data!; + expect(summary.approved).toBe(2); + expect(summary.isReady).toBe(false); + + // Third approval - threshold reached + approveConsensusTransaction(id, "participant-3"); + summary = getConsensusSummaryResult(id).data!; + expect(summary.approved).toBe(3); + expect(summary.isReady).toBe(true); + + // Finalize + const finalizeResult = finalizeConsensusTransaction(id); + expect(finalizeResult.status).toBe("ok"); + expect(finalizeResult.data!.state).toBe(ConsensusState.FINALIZED); + }); + }); +}); diff --git a/src/transaction/consensusCore.ts b/src/transaction/consensusCore.ts new file mode 100644 index 0000000..735ea7f --- /dev/null +++ b/src/transaction/consensusCore.ts @@ -0,0 +1,331 @@ +/** + * Core consensus transaction workflow management. + * Handles creation, approval tracking, and finalization. + */ + +import { randomUUID } from "crypto"; +import type { + ConsensusTransaction, + ConsensusParticipant, + ApprovalDecision, + CreateConsensusOptions, + ConsensusSummary, + ConsensusTransactionConfig, +} from "./consensusTypes"; +import { ConsensusState } from "./consensusTypes"; +import type { SorokitResult } from "../shared/response"; +import { SorokitErrorCode, err, ok } from "../shared/response"; + +/** + * In-memory store for consensus transactions. + * In production, this would be persisted to a database. + */ +const consensusStore = new Map(); + +/** + * Validates participant configuration. + */ +function validateParticipants( + participants: ConsensusParticipant[], +): string | null { + if (!Array.isArray(participants) || participants.length === 0) { + return "Participants must be a non-empty array"; + } + + const ids = new Set(); + for (const participant of participants) { + if (!participant.participantId || typeof participant.participantId !== "string") { + return "Each participant must have a valid participantId"; + } + if (ids.has(participant.participantId)) { + return `Duplicate participant ID: ${participant.participantId}`; + } + ids.add(participant.participantId); + } + + return null; +} + +/** + * Creates a new consensus transaction with threshold and participants. + */ +export function createConsensusTransaction( + threshold: number, + participants: ConsensusParticipant[], + options?: CreateConsensusOptions, +): SorokitResult { + // Validate threshold + if (!Number.isInteger(threshold) || threshold <= 0) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Threshold must be a positive integer", + ); + } + + // Validate participants + const participantError = validateParticipants(participants); + if (participantError) { + return err( + SorokitErrorCode.INVALID_CONFIG, + participantError, + ); + } + + // Check if threshold is achievable + if (threshold > participants.length) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Threshold ${threshold} exceeds number of participants ${participants.length}`, + ); + } + + // Initialize participants map + const participantsMap = new Map(); + for (const participant of participants) { + participantsMap.set(participant.participantId, { + ...participant, + approved: false, + rejected: false, + }); + } + + const consensus: ConsensusTransaction = { + consensusId: randomUUID(), + state: ConsensusState.PROPOSAL, + threshold, + totalParticipants: participants.length, + participants: participantsMap, + decisions: [], + createdAt: new Date().toISOString(), + transactionId: options?.transactionId, + metadata: options?.metadata, + }; + + consensusStore.set(consensus.consensusId, consensus); + return ok(consensus); +} + +/** + * Records an approval from a participant. + */ +export function approveConsensusTransaction( + consensusId: string, + participantId: string, + reason?: string, +): SorokitResult { + const consensus = consensusStore.get(consensusId); + + if (!consensus) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Consensus transaction not found: ${consensusId}`, + ); + } + + if (!consensus.participants.has(participantId)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Participant not found: ${participantId}`, + ); + } + + const participant = consensus.participants.get(participantId)!; + + // Check for duplicate approval + if (participant.approved) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Participant ${participantId} has already approved`, + ); + } + + // Record approval + participant.approved = true; + participant.respondedAt = new Date().toISOString(); + + consensus.decisions.push({ + participantId, + decision: true, + reason, + timestamp: participant.respondedAt, + }); + + // Update state if needed + if (consensus.state === ConsensusState.PROPOSAL) { + consensus.state = ConsensusState.REVIEW; + } + + return ok(getConsensusSummary(consensus)); +} + +/** + * Records a rejection from a participant. + */ +export function rejectConsensusTransaction( + consensusId: string, + participantId: string, + reason?: string, +): SorokitResult { + const consensus = consensusStore.get(consensusId); + + if (!consensus) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Consensus transaction not found: ${consensusId}`, + ); + } + + if (!consensus.participants.has(participantId)) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Participant not found: ${participantId}`, + ); + } + + const participant = consensus.participants.get(participantId)!; + + // Check for duplicate rejection + if (participant.rejected) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Participant ${participantId} has already rejected`, + ); + } + + // Record rejection + participant.rejected = true; + participant.respondedAt = new Date().toISOString(); + + consensus.decisions.push({ + participantId, + decision: false, + reason, + timestamp: participant.respondedAt, + }); + + // Update state + if (consensus.state === ConsensusState.PROPOSAL) { + consensus.state = ConsensusState.REVIEW; + } + + // If any rejection, move to REJECTED state + consensus.state = ConsensusState.REJECTED; + + return ok(getConsensusSummary(consensus)); +} + +/** + * Gets the current consensus summary. + */ +function getConsensusSummary(consensus: ConsensusTransaction): ConsensusSummary { + let approved = 0; + let rejected = 0; + + for (const participant of consensus.participants.values()) { + if (participant.approved) approved++; + if (participant.rejected) rejected++; + } + + const pending = consensus.totalParticipants - approved - rejected; + const isReady = approved >= consensus.threshold && consensus.state !== ConsensusState.REJECTED; + + return { + consensusId: consensus.consensusId, + state: consensus.state, + threshold: consensus.threshold, + totalParticipants: consensus.totalParticipants, + approved, + rejected, + pending, + isReady, + }; +} + +/** + * Retrieves the current summary of a consensus. + */ +export function getConsensusSummaryResult( + consensusId: string, +): SorokitResult { + const consensus = consensusStore.get(consensusId); + + if (!consensus) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Consensus transaction not found: ${consensusId}`, + ); + } + + return ok(getConsensusSummary(consensus)); +} + +/** + * Finalizes a consensus transaction after threshold is reached. + */ +export function finalizeConsensusTransaction( + consensusId: string, +): SorokitResult { + const consensus = consensusStore.get(consensusId); + + if (!consensus) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Consensus transaction not found: ${consensusId}`, + ); + } + + // Count approvals + let approved = 0; + for (const participant of consensus.participants.values()) { + if (participant.approved) approved++; + } + + // Check if threshold is met + if (approved < consensus.threshold) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Cannot finalize: only ${approved} approvals, ${consensus.threshold} required`, + ); + } + + // Check for rejections + for (const participant of consensus.participants.values()) { + if (participant.rejected) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Cannot finalize: proposal has been rejected`, + ); + } + } + + consensus.state = ConsensusState.FINALIZED; + consensus.finalizedAt = new Date().toISOString(); + + return ok(consensus); +} + +/** + * Retrieves a consensus transaction. + */ +export function getConsensusTransaction( + consensusId: string, +): SorokitResult { + const consensus = consensusStore.get(consensusId); + + if (!consensus) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Consensus transaction not found: ${consensusId}`, + ); + } + + return ok(consensus); +} + +/** + * Removes a consensus transaction (for cleanup/testing). + */ +export function removeConsensusTransaction(consensusId: string): SorokitResult { + consensusStore.delete(consensusId); + return ok(undefined); +} diff --git a/src/transaction/consensusTypes.ts b/src/transaction/consensusTypes.ts new file mode 100644 index 0000000..f6d37fa --- /dev/null +++ b/src/transaction/consensusTypes.ts @@ -0,0 +1,110 @@ +/** + * Multi-party transaction consensus types and workflow management. + * Coordinates N-of-M approval workflows for transactions. + */ + +/** + * Consensus state for a transaction proposal. + */ +export enum ConsensusState { + PROPOSAL = "proposal", + REVIEW = "review", + APPROVED = "approved", + REJECTED = "rejected", + FINALIZED = "finalized", +} + +/** + * Participant in a consensus workflow. + */ +export interface ConsensusParticipant { + /** Unique identifier for the participant */ + participantId: string; + /** Display name or description */ + name?: string; + /** Whether participant has approved */ + approved: boolean; + /** Whether participant has rejected */ + rejected: boolean; + /** Timestamp of approval/rejection (if applicable) */ + respondedAt?: string; +} + +/** + * Approval decision from a participant. + */ +export interface ApprovalDecision { + /** Participant ID making the decision */ + participantId: string; + /** Decision: true for approval, false for rejection */ + decision: boolean; + /** Optional reason for decision */ + reason?: string; + /** Timestamp of decision */ + timestamp: string; +} + +/** + * Configuration for creating a consensus transaction. + */ +export interface ConsensusTransactionConfig { + /** Required number of approvals to reach consensus */ + threshold: number; + /** List of participants required to approve */ + participants: ConsensusParticipant[]; + /** Optional transaction ID for linking to actual transaction */ + transactionId?: string; + /** Optional metadata about the transaction */ + metadata?: Record; +} + +/** + * Represents a consensus workflow around a transaction proposal. + */ +export interface ConsensusTransaction { + /** Unique identifier for this consensus */ + consensusId: string; + /** Current state of the consensus */ + state: ConsensusState; + /** Required threshold for approval */ + threshold: number; + /** Total number of participants */ + totalParticipants: number; + /** Participants and their approval status */ + participants: Map; + /** List of approval decisions in order */ + decisions: ApprovalDecision[]; + /** Timestamp when consensus was created */ + createdAt: string; + /** Timestamp when consensus was finalized (if applicable) */ + finalizedAt?: string; + /** Optional linked transaction ID */ + transactionId?: string; + /** Optional metadata */ + metadata?: Record; +} + +/** + * Summary of consensus status. + */ +export interface ConsensusSummary { + consensusId: string; + state: ConsensusState; + threshold: number; + totalParticipants: number; + approved: number; + rejected: number; + pending: number; + isReady: boolean; + reason?: string; +} + +/** + * Options for creating consensus transaction. + */ +export interface CreateConsensusOptions { + /** Optional transaction ID to link */ + transactionId?: string; + /** Optional metadata */ + metadata?: Record; +} diff --git a/src/transaction/index.ts b/src/transaction/index.ts index fbfcfce..a962cb9 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -303,6 +303,24 @@ export type { // ─── Fee-bump transactions (#398) ───────────────────────────────────────────── export { buildFeeBumpTransaction } from "./feeBumpTransaction"; +// ─── Escrow transactions ─────────────────────────────────────────────────────── +export { + buildEscrowTransaction, + validateEscrow, + validateEscrowAction, + createEscrowRelease, + createEscrowRefund, + createEscrowDispute, + isEscrowExpired, +} from "./escrow"; +export type { + EscrowAction, + EscrowState, + EscrowTiming, + EscrowParams, + EscrowValidation, +} from "./escrow"; + // ─── Asset pair trading logic (#209) ─────────────────────────────────────────── export { createAssetPair, @@ -506,3 +524,41 @@ export type { ExecutionPlan, DependencyPlanResult, } from "./dependencyGraph"; + +// ─── Multi-party transaction consensus (#507) ──────────────────────────────── +export { + createConsensusTransaction, + approveConsensusTransaction, + rejectConsensusTransaction, + getConsensusSummaryResult, + finalizeConsensusTransaction, + getConsensusTransaction, + removeConsensusTransaction, +} from "./consensusCore"; +export type { + ConsensusState, + ConsensusParticipant, + ApprovalDecision, + ConsensusTransactionConfig, + ConsensusTransaction, + ConsensusSummary, + CreateConsensusOptions, +} from "./consensusTypes"; + +// ─── Transaction XDR encoding optimization (#505) ─────────────────────────── +export { + encodeTransaction, + decodeTransaction, + registerBasePayload, + clearPayloadCache, + getPayloadCacheStats, +} from "./xdrEncodingCore"; +export type { + EncodedTransaction, + EncodingMetadata, + EncodingStrategy, + EncodingConfig, + EncodingResult, + DecodingResult, + TransactionDelta, +} from "./xdrEncodingTypes"; diff --git a/src/transaction/xdrEncoding.test.ts b/src/transaction/xdrEncoding.test.ts new file mode 100644 index 0000000..57cdddf --- /dev/null +++ b/src/transaction/xdrEncoding.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for transaction XDR encoding optimization. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + encodeTransaction, + decodeTransaction, + registerBasePayload, + clearPayloadCache, + getPayloadCacheStats, +} from "./xdrEncodingCore"; +import { EncodingStrategy } from "./xdrEncodingTypes"; + +// Sample XDR payloads for testing +const SMALL_XDR = "AAAAEgAAAABgeSoq"; +const LARGE_XDR = "AAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoqAAAAEgAAAABgeSoq"; +const REPEATED_XDR = "AAAAEgAAAABgeSoqAAAAEgAAAABgeSoq"; + +describe("XDR Encoding Optimization", () => { + beforeEach(() => { + clearPayloadCache(); + }); + + describe("encodeTransaction", () => { + it("should handle uncompressed small payloads", async () => { + const result = await encodeTransaction(SMALL_XDR, EncodingStrategy.NONE); + + expect(result.status).toBe("ok"); + expect(result.data!.encoded.metadata.strategy).toBe("none"); + expect(result.data!.optimized).toBe(false); + }); + + it("should reject empty XDR", async () => { + const result = await encodeTransaction(""); + + expect(result.status).toBe("error"); + }); + + it("should compress large payloads", async () => { + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + + expect(result.status).toBe("ok"); + expect(result.data!.encoded.metadata.strategy).toBe("deflate"); + expect(result.data!.encoded.metadata.compressionRatio).toBeLessThan(1); + }); + + it("should auto-select compression strategy", async () => { + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.AUTO); + + expect(result.status).toBe("ok"); + expect(result.data!.encoded.metadata.strategy).toBeDefined(); + }); + + it("should bypass compression if overhead exceeds threshold", async () => { + // Use a very restrictive overhead threshold + const result = await encodeTransaction(SMALL_XDR, EncodingStrategy.DEFLATE, { + maxCompressionOverhead: 1, // 1% + minCompressionSize: 1, // Very low threshold + }); + + expect(result.status).toBe("ok"); + // Small payloads may not compress well, so might be uncompressed + }); + + it("should skip compression for small payloads", async () => { + const result = await encodeTransaction(SMALL_XDR, EncodingStrategy.DEFLATE, { + minCompressionSize: 1000, // Larger threshold + }); + + expect(result.status).toBe("ok"); + expect(result.data!.encoded.metadata.strategy).toBe("none"); + }); + + it("should support delta encoding", async () => { + // Register base payload + const baseId = registerBasePayload(REPEATED_XDR); + + // Encode similar payload with delta strategy + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.DELTA, { + enableDeltaEncoding: true, + }); + + expect(result.status).toBe("ok"); + // May fall back to deflate if no suitable base found + expect( + [EncodingStrategy.DELTA, EncodingStrategy.DEFLATE, EncodingStrategy.NONE].includes( + result.data!.encoded.metadata.strategy as any, + ), + ).toBe(true); + }); + }); + + describe("decodeTransaction", () => { + it("should decode uncompressed payloads", async () => { + const encoded = await encodeTransaction(SMALL_XDR, EncodingStrategy.NONE); + const result = await decodeTransaction(encoded.data!.encoded); + + expect(result.status).toBe("ok"); + expect(result.data!.xdr).toBe(SMALL_XDR); + expect(result.data!.verified).toBe(true); + }); + + it("should decode compressed payloads", async () => { + const encoded = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + const result = await decodeTransaction(encoded.data!.encoded); + + expect(result.status).toBe("ok"); + expect(result.data!.xdr).toBe(LARGE_XDR); + expect(result.data!.verified).toBe(true); + }); + + it("should reject invalid encoded format", async () => { + const result = await decodeTransaction(null as any); + + expect(result.status).toBe("error"); + }); + + it("should handle delta decoding", async () => { + // Register base payload + registerBasePayload(REPEATED_XDR); + + // Encode with delta + const encoded = await encodeTransaction(LARGE_XDR, EncodingStrategy.DELTA, { + enableDeltaEncoding: true, + }); + + if (encoded.data!.encoded.metadata.strategy === "delta") { + const result = await decodeTransaction(encoded.data!.encoded); + + expect(result.status).toBe("ok"); + // Verification may fail due to size mismatch, but XDR should be reconstructed + expect(result.data!.xdr).toBeDefined(); + } + }); + + it("should fail gracefully on corrupted payload", async () => { + const encoded = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + + // Corrupt the payload + encoded.data!.encoded.payload[0] = 0xff; + + const result = await decodeTransaction(encoded.data!.encoded); + + // Should error on decompression failure + expect(result.status).toBe("error"); + }); + }); + + describe("round-trip encoding", () => { + it("should preserve XDR through compression round-trip", async () => { + const encoded = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + const decoded = await decodeTransaction(encoded.data!.encoded); + + expect(decoded.status).toBe("ok"); + expect(decoded.data!.xdr).toBe(LARGE_XDR); + }); + + it("should preserve XDR through uncompressed round-trip", async () => { + const encoded = await encodeTransaction(SMALL_XDR, EncodingStrategy.NONE); + const decoded = await decodeTransaction(encoded.data!.encoded); + + expect(decoded.status).toBe("ok"); + expect(decoded.data!.xdr).toBe(SMALL_XDR); + }); + }); + + describe("payload cache management", () => { + it("should register base payloads", () => { + const id = registerBasePayload(LARGE_XDR); + + expect(id).toBeDefined(); + expect(typeof id).toBe("string"); + }); + + it("should return consistent IDs for same payload", () => { + const id1 = registerBasePayload(LARGE_XDR); + const id2 = registerBasePayload(LARGE_XDR); + + expect(id1).toBe(id2); + }); + + it("should track cache statistics", () => { + registerBasePayload(LARGE_XDR); + registerBasePayload(SMALL_XDR); + + const stats = getPayloadCacheStats(); + + expect(stats.entries).toBe(2); + expect(stats.size).toBeGreaterThan(0); + }); + + it("should clear cache on request", () => { + registerBasePayload(LARGE_XDR); + clearPayloadCache(); + + const stats = getPayloadCacheStats(); + + expect(stats.entries).toBe(0); + expect(stats.size).toBe(0); + }); + }); + + describe("compression efficiency", () => { + it("should compute compression ratio", async () => { + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + + expect(result.data!.encoded.metadata.compressionRatio).toBeGreaterThan(0); + expect(result.data!.encoded.metadata.compressionRatio).toBeLessThanOrEqual(1.5); // Allow some overhead + }); + + it("should indicate when compression is worthwhile", async () => { + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + + expect(result.data!.encoded.metadata.worthCompressing).toBeDefined(); + expect(typeof result.data!.encoded.metadata.worthCompressing).toBe("boolean"); + }); + + it("should set optimized flag correctly", async () => { + const small = await encodeTransaction(SMALL_XDR, EncodingStrategy.DEFLATE, { + minCompressionSize: 1000, + }); + expect(small.data!.optimized).toBe(false); + + const large = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE); + // May or may not be optimized depending on compression efficiency + expect(typeof large.data!.optimized).toBe("boolean"); + }); + }); + + describe("configuration options", () => { + it("should respect compression level", async () => { + const level6 = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE, { + compressionLevel: 6, + }); + const level9 = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE, { + compressionLevel: 9, + }); + + expect(level6.status).toBe("ok"); + expect(level9.status).toBe("ok"); + // Higher compression level should produce same or smaller output + expect(level9.data!.encoded.payload.length).toBeLessThanOrEqual( + level6.data!.encoded.payload.length * 1.1, + ); // Allow 10% margin + }); + + it("should fall back on compression failure", async () => { + // Even with invalid config, should fall back gracefully + const result = await encodeTransaction(LARGE_XDR, EncodingStrategy.DEFLATE, { + compressionLevel: 10, // Invalid level + }); + + expect(result.status).toBe("ok"); + expect(result.data!.encoded.payload).toBeDefined(); + }); + }); +}); diff --git a/src/transaction/xdrEncodingCore.ts b/src/transaction/xdrEncodingCore.ts new file mode 100644 index 0000000..84bf84f --- /dev/null +++ b/src/transaction/xdrEncodingCore.ts @@ -0,0 +1,383 @@ +/** + * XDR encoding optimization for bandwidth efficiency. + * Provides compression, delta-based encoding, and decompression utilities. + */ + +import zlib from "zlib"; +import crypto from "crypto"; +import type { + EncodedTransaction, + EncodingMetadata, + EncodingStrategy, + EncodingConfig, + EncodingResult, + DecodingResult, + TransactionDelta, +} from "./xdrEncodingTypes"; +import { SorokitErrorCode, err, ok } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +// Default configuration +const DEFAULT_CONFIG: Required = { + minCompressionSize: 256, // Only compress if larger than 256 bytes + maxCompressionOverhead: 10, // Don't compress if overhead > 10% + enableDeltaEncoding: true, + compressionLevel: 6, +}; + +/** + * In-memory cache for base payloads (used for delta encoding). + * In production, this would be persisted. + */ +const payloadCache = new Map(); + +/** + * Creates a hash of a payload for identification. + */ +function createPayloadHash(data: string | Buffer): string { + if (typeof data === "string") { + data = Buffer.from(data, "utf-8"); + } + return crypto.createHash("sha256").update(data).digest("hex").substring(0, 16); +} + +/** + * Compresses data using DEFLATE. + */ +function deflateCompress( + data: Buffer, + level: number, +): Promise { + return new Promise((resolve, reject) => { + zlib.deflate(data, { level }, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +/** + * Decompresses DEFLATE-compressed data. + */ +function deflateDecompress(data: Buffer): Promise { + return new Promise((resolve, reject) => { + zlib.inflate(data, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +/** + * Computes differences between two XDR strings for delta encoding. + */ +function computeDelta(baseXdr: string, currentXdr: string): Buffer { + // Simple delta representation: store differences as JSON + const baseBuf = Buffer.from(baseXdr, "utf-8"); + const currentBuf = Buffer.from(currentXdr, "utf-8"); + + // Find common prefix + let i = 0; + while (i < baseBuf.length && i < currentBuf.length && baseBuf[i] === currentBuf[i]) { + i++; + } + + // Encode: [prefix_length][changes] + const delta = { + prefixLen: i, + changes: currentBuf.subarray(i).toString("base64"), + }; + + return Buffer.from(JSON.stringify(delta), "utf-8"); +} + +/** + * Applies a delta to reconstruct the original XDR. + */ +function applyDelta(baseXdr: string, delta: Buffer): string { + const deltaObj = JSON.parse(delta.toString("utf-8")); + const baseBuf = Buffer.from(baseXdr, "utf-8"); + const changes = Buffer.from(deltaObj.changes, "base64"); + + const reconstructed = Buffer.concat([ + baseBuf.subarray(0, deltaObj.prefixLen), + changes, + ]); + + return reconstructed.toString("utf-8"); +} + +/** + * Encodes an XDR transaction using the specified strategy. + */ +export async function encodeTransaction( + xdr: string, + strategy: EncodingStrategy = "auto", + config?: EncodingConfig, +): Promise> { + const fullConfig = { ...DEFAULT_CONFIG, ...config }; + + if (!xdr || typeof xdr !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "XDR must be a non-empty string", + ); + } + + const xdrBuffer = Buffer.from(xdr, "utf-8"); + const originalSize = xdrBuffer.length; + + // Check if compression is worth it + if (originalSize < fullConfig.minCompressionSize) { + return ok({ + optimized: false, + encoded: { + payload: xdrBuffer, + metadata: { + strategy: "none", + originalSize, + encodedSize: originalSize, + compressionRatio: 1, + worthCompressing: false, + }, + }, + originalXdr: xdr, + }); + } + + let selectedStrategy = strategy; + if (strategy === "auto") { + // Auto-select best strategy + selectedStrategy = fullConfig.enableDeltaEncoding ? "deflate" : "deflate"; + } + + try { + if (selectedStrategy === "deflate") { + const compressed = await deflateCompress(xdrBuffer, fullConfig.compressionLevel); + const compressionRatio = compressed.length / originalSize; + const overhead = (compressed.length - originalSize) / originalSize; + + // Check if compression overhead is acceptable + if (overhead > fullConfig.maxCompressionOverhead / 100 && originalSize > fullConfig.minCompressionSize) { + return ok({ + optimized: false, + encoded: { + payload: xdrBuffer, + metadata: { + strategy: "none", + originalSize, + encodedSize: originalSize, + compressionRatio: 1, + worthCompressing: false, + }, + }, + originalXdr: xdr, + }); + } + + const metadata: EncodingMetadata = { + strategy: "deflate", + originalSize, + encodedSize: compressed.length, + compressionRatio, + worthCompressing: compressed.length < originalSize, + }; + + return ok({ + optimized: compressed.length < originalSize, + encoded: { + payload: compressed, + metadata, + }, + }); + } else if (selectedStrategy === "delta" && fullConfig.enableDeltaEncoding) { + // Find a suitable base payload + let bestBase: { id: string; xdr: string; similarity: number } | null = null; + + for (const [id, { xdr: baseXdr }] of payloadCache.entries()) { + // Simple similarity: count matching characters at start + let matches = 0; + for (let i = 0; i < Math.min(xdr.length, baseXdr.length); i++) { + if (xdr[i] === baseXdr[i]) matches++; + else break; + } + const similarity = matches / baseXdr.length; + if (similarity > 0.8) { + if (!bestBase || similarity > bestBase.similarity) { + bestBase = { id, xdr: baseXdr, similarity }; + } + } + } + + if (bestBase) { + const deltaBuffer = computeDelta(bestBase.xdr, xdr); + const metadata: EncodingMetadata = { + strategy: "delta", + originalSize, + encodedSize: deltaBuffer.length, + compressionRatio: deltaBuffer.length / originalSize, + worthCompressing: deltaBuffer.length < originalSize, + basePayloadId: bestBase.id, + }; + + return ok({ + optimized: deltaBuffer.length < originalSize, + encoded: { + payload: deltaBuffer, + metadata, + }, + }); + } + + // Fall back to deflate if no suitable base + const compressed = await deflateCompress(xdrBuffer, fullConfig.compressionLevel); + const metadata: EncodingMetadata = { + strategy: "deflate", + originalSize, + encodedSize: compressed.length, + compressionRatio: compressed.length / originalSize, + worthCompressing: compressed.length < originalSize, + }; + + return ok({ + optimized: compressed.length < originalSize, + encoded: { + payload: compressed, + metadata, + }, + }); + } + + // Default: no compression + return ok({ + optimized: false, + encoded: { + payload: xdrBuffer, + metadata: { + strategy: "none", + originalSize, + encodedSize: originalSize, + compressionRatio: 1, + worthCompressing: false, + }, + }, + originalXdr: xdr, + }); + } catch (error) { + // Compression failed, fall back to uncompressed + return ok({ + optimized: false, + encoded: { + payload: xdrBuffer, + metadata: { + strategy: "none", + originalSize, + encodedSize: originalSize, + compressionRatio: 1, + worthCompressing: false, + }, + }, + originalXdr: xdr, + }); + } +} + +/** + * Decodes a previously encoded transaction. + */ +export async function decodeTransaction( + encoded: EncodedTransaction, +): Promise> { + if (!encoded || !encoded.metadata || !encoded.payload) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Invalid encoded transaction format", + ); + } + + try { + let xdr: string; + + if (encoded.metadata.strategy === "none") { + xdr = encoded.payload.toString("utf-8"); + } else if (encoded.metadata.strategy === "deflate") { + const decompressed = await deflateDecompress(encoded.payload); + xdr = decompressed.toString("utf-8"); + } else if (encoded.metadata.strategy === "delta") { + if (!encoded.metadata.basePayloadId) { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Delta encoding missing basePayloadId", + ); + } + + const baseData = payloadCache.get(encoded.metadata.basePayloadId); + if (!baseData) { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Base payload not found: ${encoded.metadata.basePayloadId}`, + ); + } + + xdr = applyDelta(baseData.xdr, encoded.payload); + } else { + return err( + SorokitErrorCode.INVALID_CONFIG, + `Unknown encoding strategy: ${encoded.metadata.strategy}`, + ); + } + + // Verify size + if (xdr.length !== encoded.metadata.originalSize) { + return ok({ + xdr, + verified: false, + verificationError: `Size mismatch: expected ${encoded.metadata.originalSize}, got ${xdr.length}`, + }); + } + + return ok({ + xdr, + verified: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return err( + SorokitErrorCode.INVALID_CONFIG, + `Failed to decode transaction: ${message}`, + ); + } +} + +/** + * Registers a payload as a potential base for delta encoding. + */ +export function registerBasePayload(xdr: string): string { + const id = createPayloadHash(xdr); + payloadCache.set(id, { + xdr, + hash: id, + }); + return id; +} + +/** + * Clears the payload cache. + */ +export function clearPayloadCache(): void { + payloadCache.clear(); +} + +/** + * Gets cache statistics. + */ +export function getPayloadCacheStats(): { size: number; entries: number } { + let size = 0; + for (const { xdr } of payloadCache.values()) { + size += xdr.length; + } + return { + size, + entries: payloadCache.size, + }; +} diff --git a/src/transaction/xdrEncodingTypes.ts b/src/transaction/xdrEncodingTypes.ts new file mode 100644 index 0000000..ab28b5d --- /dev/null +++ b/src/transaction/xdrEncodingTypes.ts @@ -0,0 +1,95 @@ +/** + * Transaction XDR encoding optimization types. + * Supports compression and delta-based representations for bandwidth efficiency. + */ + +/** + * Encoding strategy for XDR payloads. + */ +export enum EncodingStrategy { + /** No compression applied */ + NONE = "none", + /** DEFLATE compression for large payloads */ + DEFLATE = "deflate", + /** Delta-based encoding for similar transactions */ + DELTA = "delta", + /** Composite strategy (best fit selection) */ + AUTO = "auto", +} + +/** + * Metadata describing how a payload was encoded. + */ +export interface EncodingMetadata { + /** Strategy used for encoding */ + strategy: EncodingStrategy; + /** Original payload size in bytes */ + originalSize: number; + /** Encoded payload size in bytes */ + encodedSize: number; + /** Compression ratio (encodedSize / originalSize) */ + compressionRatio: number; + /** Whether compression resulted in savings */ + worthCompressing: boolean; + /** Optional base payload ID for delta encoding */ + basePayloadId?: string; +} + +/** + * Encoded transaction payload. + */ +export interface EncodedTransaction { + /** The encoded payload (bytes) */ + payload: Buffer; + /** Metadata describing the encoding */ + metadata: EncodingMetadata; +} + +/** + * Delta representation between two similar transactions. + */ +export interface TransactionDelta { + /** ID of the base transaction */ + basePayloadId: string; + /** Differences from base transaction (compressed) */ + differences: Buffer; + /** Hash of the base transaction for validation */ + baseHash: string; +} + +/** + * Configuration for encoding operations. + */ +export interface EncodingConfig { + /** Minimum payload size to consider for compression (bytes) */ + minCompressionSize?: number; + /** Maximum compression overhead threshold (percentage) */ + maxCompressionOverhead?: number; + /** Enable delta-based encoding for similar payloads */ + enableDeltaEncoding?: boolean; + /** Compression level (0-9) for DEFLATE */ + compressionLevel?: number; +} + +/** + * Result of encoding operation. + */ +export interface EncodingResult { + encoded: EncodedTransaction; + /** Whether encoding resulted in smaller payload */ + optimized: boolean; + /** Original XDR (if fallback to uncompressed) */ + originalXdr?: string; +} + +/** + * Decoding result. + */ +export interface DecodingResult { + /** Decoded XDR string */ + xdr: string; + /** Verification status */ + verified: boolean; + /** Error message if verification failed */ + verificationError?: string; +} diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 2765775..0a0b089 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -11,6 +11,30 @@ export { WalletType } from "./types"; export { generateDeviceFingerprint, evaluateDeviceTrust, DEFAULT_TRUST_THRESHOLD } from "./deviceTrust"; export type { DeviceSignals, DeviceFingerprint, TrustHistoryEntry, TrustScoreOptions, TrustEvaluation } from "./deviceTrust"; +// ─── Wallet connection throttling and abuse detection (#506) ────────────────── +export { + checkThrottle, + recordConnectionAttempt, + addToAllowlist, + addToBlocklist, + removeRateLimitRule, + getOriginState, + resetOriginState, + detectAbuse, + getConnectionStats, + clearThrottlingState, +} from "./throttlingCore"; +export type { + ThrottlingConfig, + ThrottleCheckResult, + OriginRateLimitState, + ConnectionAttempt, + RateLimitRule, + AbuseDetectionResult, + ConnectionStats, +} from "./throttlingTypes"; +export { RateLimitRuleType } from "./throttlingTypes"; + import type { PersistenceAdapter } from "./types"; export type { WalletState, diff --git a/src/wallet/throttling.test.ts b/src/wallet/throttling.test.ts new file mode 100644 index 0000000..2014422 --- /dev/null +++ b/src/wallet/throttling.test.ts @@ -0,0 +1,534 @@ +/** + * Tests for wallet connection throttling and abuse detection (#506). + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + checkThrottle, + recordConnectionAttempt, + addToAllowlist, + addToBlocklist, + removeRateLimitRule, + getOriginState, + resetOriginState, + detectAbuse, + getConnectionStats, + clearThrottlingState, +} from "./throttlingCore"; +import { SorokitErrorCode } from "../shared/response"; + +const TEST_ORIGIN = "https://example.com"; +const TEST_ORIGIN_2 = "https://other.com"; +const MALICIOUS_ORIGIN = "https://attacker.com"; + +describe("Wallet Connection Throttling", () => { + beforeEach(() => { + clearThrottlingState(); + }); + + describe("checkThrottle", () => { + it("should allow connection when throttling is disabled", () => { + const result = checkThrottle(TEST_ORIGIN, { enabled: false }); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should allow a normal connection attempt", () => { + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should reject empty origin", () => { + const result = checkThrottle(""); + + expect(result.status).toBe("error"); + expect(result.error?.code).toBe(SorokitErrorCode.INVALID_CONFIG); + }); + + it("should block an origin after exceeding rate limit", () => { + const config = { + maxAttemptsPerWindow: 3, + timeWindowMs: 60000, + blockDurationMs: 300000, + }; + + // Make enough attempts to trigger the block + for (let i = 0; i < 3; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + // Exceed the limit to trigger block + const limitResult = checkThrottle(TEST_ORIGIN, config); + expect(limitResult.status).toBe("ok"); + expect(limitResult.data!.allowed).toBe(false); + expect(limitResult.data!.reason).toContain("Rate limit exceeded"); + }); + + it("should return blockExpiresIn when blocked", () => { + const config = { + maxAttemptsPerWindow: 3, + timeWindowMs: 60000, + blockDurationMs: 300000, + }; + + for (let i = 0; i < 3; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + const result = checkThrottle(TEST_ORIGIN, config); + expect(result.status).toBe("ok"); + if (!result.data!.allowed) { + expect(result.data!.blockExpiresIn).toBeGreaterThan(0); + expect(result.data!.retryAfterMs).toBeGreaterThan(0); + } + }); + + it("should include origin state in response when allowed", () => { + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + expect(result.data!.state).toBeDefined(); + }); + + it("should track separate rate limits per origin", () => { + const config = { + maxAttemptsPerWindow: 3, + timeWindowMs: 60000, + }; + + // Block origin 1 + for (let i = 0; i < 3; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + checkThrottle(TEST_ORIGIN, config); // triggers block + + // Origin 2 should still be allowed + const result2 = checkThrottle(TEST_ORIGIN_2, config); + expect(result2.data!.allowed).toBe(true); + }); + }); + + describe("recordConnectionAttempt", () => { + it("should record a successful connection", () => { + const result = recordConnectionAttempt(TEST_ORIGIN, true); + + expect(result.status).toBe("ok"); + expect(result.data!.successfulAttempts).toBe(1); + expect(result.data!.failedAttempts).toBe(0); + expect(result.data!.totalAttempts).toBe(1); + }); + + it("should record a failed connection", () => { + const result = recordConnectionAttempt(TEST_ORIGIN, false, "TIMEOUT"); + + expect(result.status).toBe("ok"); + expect(result.data!.failedAttempts).toBe(1); + expect(result.data!.successfulAttempts).toBe(0); + }); + + it("should block origin after too many authentication failures", () => { + const config = { + maxAuthFailures: 3, + blockDurationMs: 300000, + enableAuthFailureTracking: true, + }; + + let finalState = recordConnectionAttempt(TEST_ORIGIN, false, "AUTH_REJECTED", config).data!; + recordConnectionAttempt(TEST_ORIGIN, false, "AUTH_REJECTED", config); + finalState = recordConnectionAttempt(TEST_ORIGIN, false, "AUTH_REJECTED", config).data!; + + expect(finalState.authenticationFailures).toBe(3); + expect(finalState.blocked).toBe(true); + expect(finalState.blockReason).toContain("authentication"); + }); + + it("should reset failure count on successful connection", () => { + recordConnectionAttempt(TEST_ORIGIN, false, "TIMEOUT"); + recordConnectionAttempt(TEST_ORIGIN, false, "TIMEOUT"); + const result = recordConnectionAttempt(TEST_ORIGIN, true); + + expect(result.status).toBe("ok"); + expect(result.data!.failedAttempts).toBe(0); + expect(result.data!.authenticationFailures).toBe(0); + }); + + it("should track lastAttemptAt timestamp", () => { + const before = Date.now(); + const result = recordConnectionAttempt(TEST_ORIGIN, true); + const after = Date.now(); + + expect(result.data!.lastAttemptAt).toBeGreaterThanOrEqual(before); + expect(result.data!.lastAttemptAt).toBeLessThanOrEqual(after); + }); + + it("should track lastSuccessAt on successful connection", () => { + const before = Date.now(); + const result = recordConnectionAttempt(TEST_ORIGIN, true); + const after = Date.now(); + + expect(result.data!.lastSuccessAt).toBeGreaterThanOrEqual(before); + expect(result.data!.lastSuccessAt).toBeLessThanOrEqual(after); + }); + }); + + describe("allowlist", () => { + it("should allow an allowlisted origin regardless of rate limits", () => { + addToAllowlist(TEST_ORIGIN); + + // Even if we have a burst of attempts, allowlisted origin should pass + const config = { maxAttemptsPerWindow: 1, timeWindowMs: 60000 }; + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + + const result = checkThrottle(TEST_ORIGIN, config); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should remove allowlist when expired", async () => { + addToAllowlist(TEST_ORIGIN, 1); // 1ms expiration + + // Wait for expiration + await new Promise((resolve) => setTimeout(resolve, 10)); + + const config = { maxAttemptsPerWindow: 1 }; + + // Force rate limit state + for (let i = 0; i < 2; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + const result = checkThrottle(TEST_ORIGIN, config); + + // The allowlist has expired, so should now be rate-limited + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(false); + }); + + it("should require a valid origin for allowlist", () => { + // addToAllowlist normalizes the origin, so empty would hit the guard + // Testing that a valid origin doesn't throw + const result = addToAllowlist(TEST_ORIGIN); + expect(result.status).toBe("ok"); + }); + }); + + describe("blocklist", () => { + it("should block a blocklisted origin immediately", () => { + addToBlocklist(TEST_ORIGIN, "Known malicious origin"); + + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(false); + expect(result.data!.reason).toContain("blocklisted"); + }); + + it("should remove blocklist entry when expired", async () => { + addToBlocklist(TEST_ORIGIN, "test", 1); // 1ms expiration + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const result = checkThrottle(TEST_ORIGIN, { maxAttemptsPerWindow: 100 }); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should allow removing a blocklist rule", () => { + addToBlocklist(TEST_ORIGIN, "Temporary block"); + removeRateLimitRule(TEST_ORIGIN); + + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should override allowlist with blocklist when re-added", () => { + // Ensure blocklist takes priority over allowlist re-addition order + addToAllowlist(TEST_ORIGIN); + addToBlocklist(TEST_ORIGIN, "Overriding allowlist"); + + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(false); + }); + }); + + describe("abuse detection", () => { + it("should detect rapid connection attempts", () => { + const config = { + maxAttemptsPerWindow: 5, + timeWindowMs: 60000, + }; + + for (let i = 0; i < 6; i++) { + recordConnectionAttempt(MALICIOUS_ORIGIN, false, undefined, config); + } + + const result = detectAbuse(MALICIOUS_ORIGIN, config); + + expect(result.status).toBe("ok"); + expect(result.data!.isSuspicious).toBe(true); + expect(result.data!.patterns).toContain("rapid_connection_attempts"); + }); + + it("should detect repeated authentication failures", () => { + const config = { + maxAuthFailures: 2, + enableAuthFailureTracking: true, + }; + + recordConnectionAttempt(MALICIOUS_ORIGIN, false, "AUTH_REJECTED", config); + recordConnectionAttempt(MALICIOUS_ORIGIN, false, "AUTH_REJECTED", config); + recordConnectionAttempt(MALICIOUS_ORIGIN, false, "AUTH_REJECTED", config); + + const result = detectAbuse(MALICIOUS_ORIGIN, config); + + expect(result.status).toBe("ok"); + expect(result.data!.patterns).toContain("authentication_failures"); + }); + + it("should detect high failure rate", () => { + // Record many consecutive failures (no success, so failedAttempts stays high) + for (let i = 0; i < 9; i++) { + recordConnectionAttempt(MALICIOUS_ORIGIN, false); + } + + const result = detectAbuse(MALICIOUS_ORIGIN); + + expect(result.status).toBe("ok"); + // 9 failures / 9 total = 100% failure rate → high_failure_rate pattern + expect(result.data!.patterns).toContain("high_failure_rate"); + }); + + it("should return confidence score between 0 and 1", () => { + const result = detectAbuse(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.confidence).toBeGreaterThanOrEqual(0); + expect(result.data!.confidence).toBeLessThanOrEqual(1); + }); + + it("should provide recommendations for suspicious origins", () => { + const config = { maxAttemptsPerWindow: 2, timeWindowMs: 60000 }; + + for (let i = 0; i < 10; i++) { + recordConnectionAttempt(MALICIOUS_ORIGIN, false, undefined, config); + } + + const result = detectAbuse(MALICIOUS_ORIGIN, config); + + expect(result.status).toBe("ok"); + if (result.data!.isSuspicious) { + expect(result.data!.recommendations.length).toBeGreaterThan(0); + } + }); + + it("should not flag legitimate origins as suspicious", () => { + // Just a couple of attempts + recordConnectionAttempt(TEST_ORIGIN, true); + recordConnectionAttempt(TEST_ORIGIN, true); + + const result = detectAbuse(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.isSuspicious).toBe(false); + expect(result.data!.confidence).toBe(0); + }); + }); + + describe("origin state management", () => { + it("should get origin state", () => { + recordConnectionAttempt(TEST_ORIGIN, true); + + const result = getOriginState(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.origin).toBeDefined(); + expect(result.data!.totalAttempts).toBe(1); + }); + + it("should reset origin state", () => { + recordConnectionAttempt(TEST_ORIGIN, false); + recordConnectionAttempt(TEST_ORIGIN, false); + + resetOriginState(TEST_ORIGIN); + + const result = getOriginState(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.totalAttempts).toBe(0); + }); + + it("should reset blocked status after block expires", async () => { + const config = { + maxAttemptsPerWindow: 2, + timeWindowMs: 60000, + blockDurationMs: 1, // 1ms block + }; + + for (let i = 0; i < 2; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + // Trigger the block + const blockedResult = checkThrottle(TEST_ORIGIN, config); + expect(blockedResult.data!.allowed).toBe(false); + + // Wait for block to expire + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Clear history so recent attempts window is empty too + resetOriginState(TEST_ORIGIN); + + const result = checkThrottle(TEST_ORIGIN, config); + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + }); + + describe("connection statistics", () => { + it("should track total connection stats", () => { + recordConnectionAttempt(TEST_ORIGIN, true); + recordConnectionAttempt(TEST_ORIGIN, false); + recordConnectionAttempt(TEST_ORIGIN_2, true); + + const stats = getConnectionStats(); + + expect(stats.totalAttempts).toBeGreaterThanOrEqual(3); + expect(stats.successfulConnections).toBeGreaterThanOrEqual(2); + expect(stats.failedConnections).toBeGreaterThanOrEqual(1); + }); + + it("should track blocked origins", () => { + addToBlocklist(MALICIOUS_ORIGIN, "Test block"); + + const stats = getConnectionStats(); + + expect(stats.blocklistedOrigins).toBeGreaterThanOrEqual(1); + }); + + it("should track allowlisted origins", () => { + addToAllowlist(TEST_ORIGIN); + + const stats = getConnectionStats(); + + expect(stats.allowlistedOrigins).toBeGreaterThanOrEqual(1); + }); + + it("should report total unique origins tracked", () => { + recordConnectionAttempt(TEST_ORIGIN, true); + recordConnectionAttempt(TEST_ORIGIN_2, true); + + const stats = getConnectionStats(); + + expect(stats.totalOrigins).toBeGreaterThanOrEqual(2); + }); + }); + + describe("rate limit expiry", () => { + it("should clear state on clearThrottlingState", () => { + recordConnectionAttempt(TEST_ORIGIN, true); + addToBlocklist(MALICIOUS_ORIGIN); + addToAllowlist(TEST_ORIGIN_2); + + clearThrottlingState(); + + const stats = getConnectionStats(); + + expect(stats.totalOrigins).toBe(0); + expect(stats.totalAttempts).toBe(0); + expect(stats.allowlistedOrigins).toBe(0); + expect(stats.blocklistedOrigins).toBe(0); + }); + + it("should allow connections again after block window expires", async () => { + const config = { + maxAttemptsPerWindow: 2, + timeWindowMs: 1, // 1ms window + blockDurationMs: 1, // 1ms block + }; + + for (let i = 0; i < 3; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + // Block triggered by exceeding window + checkThrottle(TEST_ORIGIN, config); + + // Wait for both window and block to expire + await new Promise((resolve) => setTimeout(resolve, 20)); + + const result = checkThrottle(TEST_ORIGIN, config); + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + }); + + describe("structured error responses", () => { + it("should return structured error when blocked", () => { + addToBlocklist(TEST_ORIGIN, "Explicitly blocked"); + + const result = checkThrottle(TEST_ORIGIN); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(false); + expect(result.data!.reason).toBeDefined(); + expect(typeof result.data!.reason).toBe("string"); + }); + + it("should include retryAfterMs when rate limited", () => { + const config = { + maxAttemptsPerWindow: 2, + timeWindowMs: 60000, + blockDurationMs: 300000, + }; + + for (let i = 0; i < 2; i++) { + recordConnectionAttempt(TEST_ORIGIN, false, undefined, config); + } + + const result = checkThrottle(TEST_ORIGIN, config); + + expect(result.status).toBe("ok"); + if (!result.data!.allowed) { + expect(result.data!.retryAfterMs).toBeDefined(); + expect(result.data!.retryAfterMs).toBeGreaterThan(0); + } + }); + }); + + describe("existing wallet integrations remain compatible", () => { + it("should not affect connections when protection is disabled", () => { + const config = { enabled: false }; + + // Even with many failed attempts, disabled throttling allows all + for (let i = 0; i < 100; i++) { + recordConnectionAttempt(TEST_ORIGIN, false); + } + + const result = checkThrottle(TEST_ORIGIN, config); + + expect(result.status).toBe("ok"); + expect(result.data!.allowed).toBe(true); + }); + + it("should normalize origin URLs consistently", () => { + // Different representations of the same origin should be treated identically + recordConnectionAttempt("https://example.com/app/page", true); + const state1 = getOriginState("https://example.com"); + + expect(state1.status).toBe("ok"); + expect(state1.data!.totalAttempts).toBe(1); + }); + }); +}); diff --git a/src/wallet/throttlingCore.ts b/src/wallet/throttlingCore.ts new file mode 100644 index 0000000..5a47c37 --- /dev/null +++ b/src/wallet/throttlingCore.ts @@ -0,0 +1,476 @@ +/** + * Wallet connection throttling and abuse detection core logic. + */ + +import type { + ThrottlingConfig, + ThrottleCheckResult, + OriginRateLimitState, + ConnectionAttempt, + RateLimitRule, + AbuseDetectionResult, + ConnectionStats, +} from "./throttlingTypes"; +import { RateLimitRuleType } from "./throttlingTypes"; +import type { SorokitResult } from "../shared/response"; +import { SorokitErrorCode, err, ok } from "../shared/response"; + +/** + * Default throttling configuration. + */ +const DEFAULT_THROTTLING_CONFIG: Required = { + maxAttemptsPerWindow: 10, + timeWindowMs: 60000, // 1 minute + maxAuthFailures: 3, + blockDurationMs: 300000, // 5 minutes + enableOriginTracking: true, + enableAuthFailureTracking: true, + reconnectGraceMs: 5000, // 5 second grace period + enabled: true, +}; + +/** + * Origin rate limit state storage. + */ +const originStates = new Map(); + +/** + * Connection attempt history. + */ +const connectionHistory: ConnectionAttempt[] = []; + +/** + * Rate limiting rules (allowlist/blocklist). + */ +const rateLimitRules = new Map(); + +/** + * Extracts origin from URL or returns as-is if already an origin string. + */ +function normalizeOrigin(origin: string): string { + try { + const url = new URL(origin); + return `${url.protocol}//${url.host}`; + } catch { + // Already an origin string or invalid URL, return as-is + return origin.toLowerCase(); + } +} + +/** + * Gets or initializes rate limit state for an origin. + */ +function getOrCreateState(origin: string): OriginRateLimitState { + const normalized = normalizeOrigin(origin); + + if (!originStates.has(normalized)) { + originStates.set(normalized, { + origin: normalized, + totalAttempts: 0, + successfulAttempts: 0, + failedAttempts: 0, + authenticationFailures: 0, + blocked: false, + }); + } + + return originStates.get(normalized)!; +} + +/** + * Checks if an origin is in the allowlist. + */ +function isAllowlisted(origin: string): boolean { + const normalized = normalizeOrigin(origin); + const rule = rateLimitRules.get(normalized); + if (rule && rule.type === RateLimitRuleType.ALLOWLIST) { + if (rule.expiresAt && rule.expiresAt < Date.now()) { + rateLimitRules.delete(normalized); + return false; + } + return true; + } + return false; +} + +/** + * Checks if an origin is in the blocklist. + */ +function isBlocklisted(origin: string): boolean { + const normalized = normalizeOrigin(origin); + const rule = rateLimitRules.get(normalized); + if (rule && rule.type === RateLimitRuleType.BLOCKLIST) { + if (rule.expiresAt && rule.expiresAt < Date.now()) { + rateLimitRules.delete(normalized); + return false; + } + return true; + } + return false; +} + +/** + * Checks throttle status and decides if connection should be allowed. + */ +export function checkThrottle( + origin: string, + config?: ThrottlingConfig, +): SorokitResult { + const fullConfig = { ...DEFAULT_THROTTLING_CONFIG, ...config }; + + if (!fullConfig.enabled) { + return ok({ + allowed: true, + }); + } + + if (!origin || typeof origin !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Origin is required", + ); + } + + const normalized = normalizeOrigin(origin); + + // Check allowlist first + if (isAllowlisted(normalized)) { + return ok({ + allowed: true, + }); + } + + // Check blocklist + if (isBlocklisted(normalized)) { + return ok({ + allowed: false, + reason: "Origin is blocklisted", + }); + } + + const state = getOrCreateState(normalized); + + // Check if currently blocked due to too many attempts + if (state.blocked && state.blockExpiresAt) { + if (Date.now() < state.blockExpiresAt) { + const remainingMs = state.blockExpiresAt - Date.now(); + return ok({ + allowed: false, + reason: `Origin temporarily blocked: ${state.blockReason || "too many failed attempts"}`, + blockExpiresIn: remainingMs, + retryAfterMs: remainingMs, + state, + }); + } else { + // Block has expired, reset + state.blocked = false; + state.blockExpiresAt = undefined; + state.blockReason = undefined; + state.authenticationFailures = 0; + } + } + + // Check rate limit window + const now = Date.now(); + const windowStart = now - fullConfig.timeWindowMs; + + // Clean up old attempts + const recentAttempts = connectionHistory.filter( + (a) => a.origin === normalized && a.timestamp > windowStart, + ); + + if (recentAttempts.length >= fullConfig.maxAttemptsPerWindow) { + // Block this origin temporarily + state.blocked = true; + state.blockExpiresAt = now + fullConfig.blockDurationMs; + state.blockReason = "Too many connection attempts"; + + return ok({ + allowed: false, + reason: "Rate limit exceeded", + blockExpiresIn: fullConfig.blockDurationMs, + retryAfterMs: fullConfig.blockDurationMs, + state, + }); + } + + return ok({ + allowed: true, + state, + }); +} + +/** + * Records a connection attempt. + */ +export function recordConnectionAttempt( + origin: string, + success: boolean, + failureReason?: string, + config?: ThrottlingConfig, +): SorokitResult { + const fullConfig = { ...DEFAULT_THROTTLING_CONFIG, ...config }; + const normalized = normalizeOrigin(origin); + + if (!normalized || typeof normalized !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Origin is required", + ); + } + + const state = getOrCreateState(normalized); + + // Record attempt + connectionHistory.push({ + origin: normalized, + timestamp: Date.now(), + success, + failureReason, + }); + + state.totalAttempts++; + state.lastAttemptAt = Date.now(); + + if (success) { + state.successfulAttempts++; + state.lastSuccessAt = Date.now(); + state.failedAttempts = 0; + state.authenticationFailures = 0; + } else { + state.failedAttempts++; + + // Track authentication failures + if ( + fullConfig.enableAuthFailureTracking && + failureReason?.includes("AUTH") + ) { + state.authenticationFailures++; + + // Block after too many auth failures + if (state.authenticationFailures >= fullConfig.maxAuthFailures) { + state.blocked = true; + state.blockExpiresAt = Date.now() + fullConfig.blockDurationMs; + state.blockReason = "Too many authentication failures"; + } + } + } + + return ok(state); +} + +/** + * Adds an origin to the allowlist. + */ +export function addToAllowlist( + origin: string, + expirationMs?: number, +): SorokitResult { + const normalized = normalizeOrigin(origin); + + if (!normalized || typeof normalized !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Origin is required", + ); + } + + // Remove from blocklist if present + rateLimitRules.delete(normalized); + + rateLimitRules.set(normalized, { + origin: normalized, + type: RateLimitRuleType.ALLOWLIST, + expiresAt: expirationMs ? Date.now() + expirationMs : undefined, + reason: "Added to allowlist", + }); + + return ok(undefined); +} + +/** + * Adds an origin to the blocklist. + */ +export function addToBlocklist( + origin: string, + reason?: string, + expirationMs?: number, +): SorokitResult { + const normalized = normalizeOrigin(origin); + + if (!normalized || typeof normalized !== "string") { + return err( + SorokitErrorCode.INVALID_CONFIG, + "Origin is required", + ); + } + + // Remove from allowlist if present + rateLimitRules.delete(normalized); + + rateLimitRules.set(normalized, { + origin: normalized, + type: RateLimitRuleType.BLOCKLIST, + expiresAt: expirationMs ? Date.now() + expirationMs : undefined, + reason: reason || "Added to blocklist", + }); + + return ok(undefined); +} + +/** + * Removes a rate limit rule for an origin. + */ +export function removeRateLimitRule(origin: string): SorokitResult { + const normalized = normalizeOrigin(origin); + rateLimitRules.delete(normalized); + return ok(undefined); +} + +/** + * Gets rate limit state for an origin. + */ +export function getOriginState( + origin: string, +): SorokitResult { + const normalized = normalizeOrigin(origin); + const state = getOrCreateState(normalized); + return ok(state); +} + +/** + * Resets rate limit state for an origin. + */ +export function resetOriginState(origin: string): SorokitResult { + const normalized = normalizeOrigin(origin); + originStates.delete(normalized); + + // Clean up history for this origin + const index = connectionHistory.findIndex((a) => a.origin === normalized); + if (index !== -1) { + connectionHistory.splice(index, 1); + } + + return ok(undefined); +} + +/** + * Detects potential abuse patterns. + */ +export function detectAbuse( + origin: string, + config?: ThrottlingConfig, +): SorokitResult { + const fullConfig = { ...DEFAULT_THROTTLING_CONFIG, ...config }; + const normalized = normalizeOrigin(origin); + + const state = getOrCreateState(normalized); + const patterns: string[] = []; + let confidence = 0; + + // Check for rapid attempts + const now = Date.now(); + const recentAttempts = connectionHistory.filter( + (a) => + a.origin === normalized && + a.timestamp > now - fullConfig.timeWindowMs, + ); + + if (recentAttempts.length >= fullConfig.maxAttemptsPerWindow) { + patterns.push("rapid_connection_attempts"); + confidence += 0.3; + } + + // Check for repeated failures + if (state.failedAttempts >= fullConfig.maxAttemptsPerWindow / 2) { + patterns.push("repeated_failures"); + confidence += 0.25; + } + + // Check for authentication failures + if (state.authenticationFailures >= fullConfig.maxAuthFailures) { + patterns.push("authentication_failures"); + confidence += 0.25; + } + + // Check failure rate + if (state.totalAttempts > 0) { + const failureRate = state.failedAttempts / state.totalAttempts; + if (failureRate > 0.8) { + patterns.push("high_failure_rate"); + confidence += 0.2; + } + } + + const recommendations: string[] = []; + if (confidence > 0.5) { + recommendations.push("Consider adding origin to temporary blocklist"); + recommendations.push("Monitor for continued suspicious activity"); + } + if (patterns.includes("authentication_failures")) { + recommendations.push("Reset authentication state"); + } + + return ok({ + isSuspicious: confidence > 0.5, + confidence: Math.min(confidence, 1), + patterns, + recommendations, + }); +} + +/** + * Gets connection statistics. + */ +export function getConnectionStats(): ConnectionStats { + let blockedOrigins = 0; + let allowlistedOrigins = 0; + let blocklistedOrigins = 0; + + for (const rule of rateLimitRules.values()) { + if (rule.expiresAt && rule.expiresAt < Date.now()) { + continue; + } + if (rule.type === RateLimitRuleType.ALLOWLIST) { + allowlistedOrigins++; + } else if (rule.type === RateLimitRuleType.BLOCKLIST) { + blocklistedOrigins++; + } + } + + for (const state of originStates.values()) { + if (state.blocked) { + blockedOrigins++; + } + } + + let successfulConnections = 0; + let failedConnections = 0; + + for (const attempt of connectionHistory) { + if (attempt.success) { + successfulConnections++; + } else { + failedConnections++; + } + } + + return { + totalOrigins: originStates.size, + blockedOrigins, + allowlistedOrigins, + blocklistedOrigins, + totalAttempts: connectionHistory.length, + successfulConnections, + failedConnections, + }; +} + +/** + * Clears all throttling state (for testing/reset). + */ +export function clearThrottlingState(): void { + originStates.clear(); + connectionHistory.length = 0; + rateLimitRules.clear(); +} diff --git a/src/wallet/throttlingTypes.ts b/src/wallet/throttlingTypes.ts new file mode 100644 index 0000000..70fef2b --- /dev/null +++ b/src/wallet/throttlingTypes.ts @@ -0,0 +1,137 @@ +/** + * Wallet connection throttling and abuse detection types. + */ + +/** + * Rate limiting rule type. + */ +export enum RateLimitRuleType { + ALLOWLIST = "allowlist", + BLOCKLIST = "blocklist", +} + +/** + * Rate limit rule for an origin. + */ +export interface RateLimitRule { + /** Origin to apply rule to */ + origin: string; + /** Type of rule */ + type: RateLimitRuleType; + /** Optional expiration timestamp */ + expiresAt?: number; + /** Reason for the rule */ + reason?: string; +} + +/** + * Connection attempt record. + */ +export interface ConnectionAttempt { + /** Origin of the connection attempt */ + origin: string; + /** Timestamp of the attempt */ + timestamp: number; + /** Whether the attempt succeeded */ + success: boolean; + /** Reason if failed (e.g., "SIGNATURE_REJECTED", "TIMEOUT") */ + failureReason?: string; +} + +/** + * Rate limit state for a specific origin. + */ +export interface OriginRateLimitState { + /** Origin identifier */ + origin: string; + /** Total connection attempts */ + totalAttempts: number; + /** Successful connections */ + successfulAttempts: number; + /** Failed attempts */ + failedAttempts: number; + /** Failed authentication attempts */ + authenticationFailures: number; + /** Whether this origin is currently blocked */ + blocked: boolean; + /** When the block expires (if applicable) */ + blockExpiresAt?: number; + /** Reason for blocking */ + blockReason?: string; + /** Timestamp of last attempt */ + lastAttemptAt?: number; + /** Timestamp of last successful connection */ + lastSuccessAt?: number; +} + +/** + * Throttling configuration. + */ +export interface ThrottlingConfig { + /** Maximum connection attempts per time window */ + maxAttemptsPerWindow?: number; + /** Time window in milliseconds */ + timeWindowMs?: number; + /** Maximum authentication failures before temporary block */ + maxAuthFailures?: number; + /** Temporary block duration in milliseconds */ + blockDurationMs?: number; + /** Enable origin-based rate limiting */ + enableOriginTracking?: boolean; + /** Enable authentication failure tracking */ + enableAuthFailureTracking?: boolean; + /** Grace period for legitimate reconnects (ms) */ + reconnectGraceMs?: number; + /** Whether throttling is enabled */ + enabled?: boolean; +} + +/** + * Throttle check result. + */ +export interface ThrottleCheckResult { + /** Whether the connection should be allowed */ + allowed: boolean; + /** Reason if denied */ + reason?: string; + /** Time until block expires (if applicable) */ + blockExpiresIn?: number; + /** Recommended retry time in milliseconds */ + retryAfterMs?: number; + /** Current state for the origin */ + state?: OriginRateLimitState; +} + +/** + * Abuse detection result. + */ +export interface AbuseDetectionResult { + /** Whether abuse is suspected */ + isSuspicious: boolean; + /** Confidence score (0-1) */ + confidence: number; + /** Detected abuse patterns */ + patterns: string[]; + /** Recommendations */ + recommendations: string[]; +} + +/** + * Connection statistics. + */ +export interface ConnectionStats { + /** Total origins tracked */ + totalOrigins: number; + /** Currently blocked origins */ + blockedOrigins: number; + /** Allowlisted origins */ + allowlistedOrigins: number; + /** Blocklisted origins */ + blocklistedOrigins: number; + /** Total connection attempts */ + totalAttempts: number; + /** Successful connections */ + successfulConnections: number; + /** Failed connections */ + failedConnections: number; +}