diff --git a/packages/stellar/src/asset-compliance.test.ts b/packages/stellar/src/asset-compliance.test.ts new file mode 100644 index 0000000..695431c --- /dev/null +++ b/packages/stellar/src/asset-compliance.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { checkAssetCompliance, loadComplianceConfig } from './asset-compliance'; +import type { ComplianceConfig } from './asset-compliance'; + +const ISSUER_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const ISSUER_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBTFC'; + +const baseConfig: ComplianceConfig = { + blocklist: [{ issuer: ISSUER_A, reason: 'Sanctioned entity' }], + jurisdictionRules: [{ jurisdiction: 'US', blockedAssets: ['TOKEN'] }], +}; + +describe('checkAssetCompliance', () => { + it('canDeploy is always true', () => { + const result = checkAssetCompliance('TOKEN', ISSUER_A, baseConfig); + expect(result.canDeploy).toBe(true); + }); + + it('returns no warnings for a clean asset', () => { + const result = checkAssetCompliance('USDC', ISSUER_B, baseConfig); + expect(result.warnings).toHaveLength(0); + expect(result.envVars).toEqual({}); + }); + + it('flags blocked issuer', () => { + const result = checkAssetCompliance('USDC', ISSUER_A, baseConfig); + expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_ISSUER_BLOCKED')).toBe(true); + expect(result.envVars.COMPLIANCE_ISSUER_BLOCKED).toBe('true'); + }); + + it('flags KYC required when issuer is blocked', () => { + const result = checkAssetCompliance('USDC', ISSUER_A, baseConfig); + expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBe('true'); + }); + + it('flags jurisdiction restriction for blocked asset code', () => { + const result = checkAssetCompliance('TOKEN', ISSUER_B, baseConfig); + expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_JURISDICTION_RESTRICTED')).toBe(true); + expect(result.envVars.COMPLIANCE_JURISDICTION_RESTRICTED).toBe('true'); + expect(result.envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS).toBe('US'); + }); + + it('lists all restricted jurisdictions comma-separated', () => { + const config: ComplianceConfig = { + blocklist: [], + jurisdictionRules: [ + { jurisdiction: 'US', blockedAssets: ['TOKEN'] }, + { jurisdiction: 'CN', blockedAssets: ['TOKEN'] }, + ], + }; + const result = checkAssetCompliance('TOKEN', ISSUER_B, config); + const jurisdictions = result.envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS!.split(','); + expect(jurisdictions).toContain('US'); + expect(jurisdictions).toContain('CN'); + }); + + it('is case-insensitive for asset code', () => { + const result = checkAssetCompliance('token', ISSUER_B, baseConfig); + expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_JURISDICTION_RESTRICTED')).toBe(true); + }); + + it('is case-insensitive for issuer address', () => { + const result = checkAssetCompliance('USDC', ISSUER_A.toLowerCase(), baseConfig); + expect(result.envVars.COMPLIANCE_ISSUER_BLOCKED).toBe('true'); + }); + + it('sets KYC required when jurisdiction restriction triggers', () => { + const result = checkAssetCompliance('TOKEN', ISSUER_B, baseConfig); + expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBe('true'); + }); + + it('does not set KYC when no issues', () => { + const result = checkAssetCompliance('USDC', ISSUER_B, baseConfig); + expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBeUndefined(); + }); + + it('accumulates both issuer and jurisdiction warnings', () => { + const result = checkAssetCompliance('TOKEN', ISSUER_A, baseConfig); + const flags = result.warnings.map((w) => w.flag); + expect(flags).toContain('COMPLIANCE_ISSUER_BLOCKED'); + expect(flags).toContain('COMPLIANCE_JURISDICTION_RESTRICTED'); + }); + + it('works with empty config (no blocklist, no rules)', () => { + const result = checkAssetCompliance('TOKEN', ISSUER_A, { blocklist: [], jurisdictionRules: [] }); + expect(result.canDeploy).toBe(true); + expect(result.warnings).toHaveLength(0); + }); +}); + +describe('loadComplianceConfig', () => { + it('returns empty lists when no override and no env vars', () => { + const config = loadComplianceConfig({ blocklist: [], jurisdictionRules: [] }); + expect(config.blocklist).toEqual([]); + expect(config.jurisdictionRules).toEqual([]); + }); + + it('uses provided override directly', () => { + const config = loadComplianceConfig(baseConfig); + expect(config.blocklist).toHaveLength(1); + expect(config.jurisdictionRules).toHaveLength(1); + }); +}); diff --git a/packages/stellar/src/asset-compliance.ts b/packages/stellar/src/asset-compliance.ts new file mode 100644 index 0000000..fb9a146 --- /dev/null +++ b/packages/stellar/src/asset-compliance.ts @@ -0,0 +1,171 @@ +/** + * Stellar Asset Compliance Checking Service (#777) + * + * Verifies assets against a configurable blocklist and propagates compliance + * flags (KYC required, restricted jurisdiction) to generated template + * environment variables. + * + * Flag propagation never blocks deployment — violations are recorded as + * deployment log warnings only. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BlocklistEntry { + /** Stellar account ID (G...) of the asset issuer. */ + issuer: string; + reason: string; +} + +export interface JurisdictionRule { + /** ISO 3166-1 alpha-2 country code, e.g. "US", "CN". */ + jurisdiction: string; + /** Asset codes blocked in this jurisdiction, e.g. ["TOKEN", "XYZ"]. */ + blockedAssets: string[]; +} + +export interface ComplianceConfig { + blocklist: BlocklistEntry[]; + jurisdictionRules: JurisdictionRule[]; +} + +export type ComplianceFlagKey = + | 'COMPLIANCE_KYC_REQUIRED' + | 'COMPLIANCE_ISSUER_BLOCKED' + | 'COMPLIANCE_JURISDICTION_RESTRICTED' + | 'COMPLIANCE_RESTRICTED_JURISDICTIONS'; + +/** Environment variables propagated to the generated template configuration. */ +export type ComplianceEnvVars = Partial>; + +export interface ComplianceWarning { + flag: ComplianceFlagKey; + message: string; +} + +export interface ComplianceCheckResult { + /** Always true — compliance issues are warnings, not deployment blockers. */ + canDeploy: boolean; + warnings: ComplianceWarning[]; + /** Variables to merge into the deployment environment. */ + envVars: ComplianceEnvVars; +} + +// --------------------------------------------------------------------------- +// Default config loader (override in tests or provide your own) +// --------------------------------------------------------------------------- + +/** + * Load compliance config from the environment or a provided source. + * + * In production the blocklist and jurisdiction rules live in Supabase and + * are injected via `COMPLIANCE_BLOCKLIST_JSON` / `COMPLIANCE_JURISDICTION_JSON` + * environment variables (serialised JSON arrays). + */ +export function loadComplianceConfig(override?: Partial): ComplianceConfig { + let blocklist: BlocklistEntry[] = override?.blocklist ?? []; + let jurisdictionRules: JurisdictionRule[] = override?.jurisdictionRules ?? []; + + if (!override?.blocklist) { + try { + const raw = process.env.COMPLIANCE_BLOCKLIST_JSON; + if (raw) blocklist = JSON.parse(raw) as BlocklistEntry[]; + } catch { + // Malformed env var — treat as empty blocklist + } + } + + if (!override?.jurisdictionRules) { + try { + const raw = process.env.COMPLIANCE_JURISDICTION_JSON; + if (raw) jurisdictionRules = JSON.parse(raw) as JurisdictionRule[]; + } catch { + // Malformed env var — treat as empty rules + } + } + + return { blocklist, jurisdictionRules }; +} + +// --------------------------------------------------------------------------- +// Core compliance check +// --------------------------------------------------------------------------- + +/** + * Run compliance checks for a Stellar asset before template generation. + * + * Checks performed: + * 1. Issuer blocklist — flags `COMPLIANCE_ISSUER_BLOCKED` + * 2. Jurisdiction restrictions — flags `COMPLIANCE_JURISDICTION_RESTRICTED` + * and `COMPLIANCE_RESTRICTED_JURISDICTIONS` + * 3. KYC requirement — flags `COMPLIANCE_KYC_REQUIRED` when either of the + * above triggers (i.e. a restricted asset always requires KYC) + * + * All flags are propagated as environment variables and recorded as deployment + * log warnings. Deployment is never blocked. + * + * @param assetCode - Stellar asset code, e.g. "USDC" + * @param issuer - Issuer G-address + * @param config - Compliance config (defaults to env-loaded config) + * @returns Check result with `canDeploy: true`, warnings, and envVars + * + * @example + * ```typescript + * const result = checkAssetCompliance('TOKEN', 'GABC...', config); + * // Merge envVars into deployment environment + * Object.assign(deploymentEnv, result.envVars); + * // Write warnings to deployment log + * result.warnings.forEach(w => log.warn(w.message)); + * ``` + */ +export function checkAssetCompliance( + assetCode: string, + issuer: string, + config?: Partial, +): ComplianceCheckResult { + const { blocklist, jurisdictionRules } = loadComplianceConfig(config); + + const warnings: ComplianceWarning[] = []; + const envVars: ComplianceEnvVars = {}; + + // 1. Issuer blocklist check + const blockEntry = blocklist.find( + (e) => e.issuer.toLowerCase() === issuer.toLowerCase(), + ); + if (blockEntry) { + warnings.push({ + flag: 'COMPLIANCE_ISSUER_BLOCKED', + message: `Asset issuer ${issuer} is on the compliance blocklist: ${blockEntry.reason}`, + }); + envVars.COMPLIANCE_ISSUER_BLOCKED = 'true'; + } + + // 2. Jurisdiction restriction check + const restrictedIn: string[] = []; + for (const rule of jurisdictionRules) { + if (rule.blockedAssets.some((a) => a.toUpperCase() === assetCode.toUpperCase())) { + restrictedIn.push(rule.jurisdiction); + } + } + if (restrictedIn.length > 0) { + warnings.push({ + flag: 'COMPLIANCE_JURISDICTION_RESTRICTED', + message: `Asset ${assetCode} is restricted in jurisdictions: ${restrictedIn.join(', ')}`, + }); + envVars.COMPLIANCE_JURISDICTION_RESTRICTED = 'true'; + envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS = restrictedIn.join(','); + } + + // 3. KYC flag — required whenever any compliance issue is present + if (warnings.length > 0) { + envVars.COMPLIANCE_KYC_REQUIRED = 'true'; + } + + return { + canDeploy: true, // never blocks deployment + warnings, + envVars, + }; +} diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index 822b872..44c1ccb 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -14,3 +14,4 @@ export * from './soroban-ttl-manager'; export * from './multi-party-issuance'; export * from './fee-bump-orchestrator'; export * from './account-merge-protection'; +export * from './asset-compliance'; diff --git a/packages/stellar/src/soroban.ts b/packages/stellar/src/soroban.ts index d1b9e9b..4575bef 100644 --- a/packages/stellar/src/soroban.ts +++ b/packages/stellar/src/soroban.ts @@ -532,3 +532,264 @@ export function verifyContractAddress( ): boolean { return deriveContractAddress(deployer, salt, wasmHash) === deployed; } + +// --------------------------------------------------------------------------- +// WASM Binary Size Optimization Validation Pipeline (#776) +// --------------------------------------------------------------------------- + +/** WASM section type IDs as defined in the WebAssembly binary format spec. */ +const WASM_SECTION = { + CUSTOM: 0, + TYPE: 1, + IMPORT: 2, + FUNCTION: 3, + TABLE: 4, + MEMORY: 5, + GLOBAL: 6, + EXPORT: 7, + START: 8, + ELEMENT: 9, + CODE: 10, + DATA: 11, + DATA_COUNT: 12, +} as const; + +/** Known Soroban host function import module name. */ +const SOROBAN_HOST_MODULE = 'v'; + +export interface WasmSectionBreakdown { + /** Code section size in bytes (function bodies). */ + codeSection: number; + /** Data section size in bytes (initialized memory segments). */ + dataSection: number; + /** Import section size in bytes. */ + importSection: number; + /** Sum of all custom section sizes in bytes (debug info, names, etc.). */ + customSections: number; + /** Total binary size in bytes. */ + total: number; +} + +export type OptimizationAction = + | 'strip-debug-info' + | 'enable-wasm-opt' + | 'remove-unused-imports'; + +export interface WasmOptimizationIssue { + type: 'unused-import' | 'debug-section' | 'unoptimized-data' | 'redundant-types'; + description: string; + /** Estimated byte savings if the issue is resolved. */ + estimatedSavings: number; + action: OptimizationAction; +} + +export interface WasmOptimizationReport { + /** True when the binary passes the size limit. */ + withinLimit: boolean; + sizeBreakdown: WasmSectionBreakdown; + issues: WasmOptimizationIssue[]; + suggestions: OptimizationAction[]; + /** Total estimated savings across all issues, in bytes. */ + totalEstimatedSavings: number; +} + +/** Read a LEB128 unsigned integer from a DataView, returning [value, bytesRead]. */ +function readULEB128(view: DataView, offset: number): [number, number] { + let result = 0; + let shift = 0; + let bytesRead = 0; + while (offset + bytesRead < view.byteLength) { + const byte = view.getUint8(offset + bytesRead); + bytesRead++; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) break; + shift += 7; + if (shift >= 35) break; // safety: cap at 5 bytes for u32 + } + return [result, bytesRead]; +} + +/** Minimal UTF-8 string reader from a DataView. */ +function readString(view: DataView, offset: number, len: number): string { + const bytes = new Uint8Array(view.buffer, view.byteOffset + offset, len); + return typeof TextDecoder !== 'undefined' + ? new TextDecoder().decode(bytes) + : Buffer.from(bytes).toString('utf8'); +} + +/** + * Analyzes a WASM binary for size optimization opportunities. + * + * Parses the WASM section table to build a size breakdown and detect: + * - Custom sections (debug info, name section) that can be stripped + * - Import section entries that reference unused host function modules + * - Oversized data segments relative to code + * - Redundant type section entries + * + * Runs in O(n) over the binary length and completes well under 2 seconds + * for a 64 KB binary. + * + * @param wasmBinary - Raw WASM bytes + * @returns Optimization report with section breakdown, issues, and suggestions + * + * @example + * ```typescript + * const report = analyzeWasmOptimization(fs.readFileSync('contract.wasm')); + * if (report.issues.length > 0) { + * console.log('Suggestions:', report.suggestions); + * console.log('Potential savings:', report.totalEstimatedSavings, 'bytes'); + * } + * ``` + */ +export function analyzeWasmOptimization(wasmBinary: Buffer | Uint8Array): WasmOptimizationReport { + const buf = wasmBinary instanceof Buffer ? wasmBinary : Buffer.from(wasmBinary); + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const total = buf.length; + + const breakdown: WasmSectionBreakdown = { + codeSection: 0, + dataSection: 0, + importSection: 0, + customSections: 0, + total, + }; + + const issues: WasmOptimizationIssue[] = []; + + // Validate WASM magic + version (8 bytes header) + // WASM magic bytes: 0x00 0x61 0x73 0x6d (\0asm), read as big-endian uint32 + const WASM_MAGIC = 0x0061736d; + if (total < 8 || view.getUint32(0, false) !== WASM_MAGIC) { + return { + withinLimit: total <= MAX_WASM_SIZE_BYTES, + sizeBreakdown: breakdown, + issues, + suggestions: [], + totalEstimatedSavings: 0, + }; + } + + let offset = 8; + let importCount = 0; + let sorobanImportCount = 0; + let typeCount = 0; + let funcCount = 0; + + // Walk section table + while (offset < total - 1) { + if (offset >= total) break; + const sectionId = view.getUint8(offset); + offset += 1; + + const [sectionSize, szBytes] = readULEB128(view, offset); + offset += szBytes; + + const sectionStart = offset; + + if (sectionId === WASM_SECTION.CODE) { + breakdown.codeSection += sectionSize; + } else if (sectionId === WASM_SECTION.DATA) { + breakdown.dataSection += sectionSize; + } else if (sectionId === WASM_SECTION.IMPORT) { + breakdown.importSection += sectionSize; + // Scan imports to detect host function usage + let pos = sectionStart; + const [count, cb] = readULEB128(view, pos); + pos += cb; + importCount += count; + for (let i = 0; i < count && pos < sectionStart + sectionSize; i++) { + const [modLen, mb] = readULEB128(view, pos); pos += mb; + const modName = pos + modLen <= total ? readString(view, pos, modLen) : ''; + pos += modLen; + const [fldLen, fb] = readULEB128(view, pos); pos += fb; + pos += fldLen; // skip field name + const importKind = pos < total ? view.getUint8(pos) : 0; pos += 1; + if (importKind === 0 /* function */) { + pos += readULEB128(view, pos)[1]; // skip type index + if (modName === SOROBAN_HOST_MODULE) sorobanImportCount++; + } else if (importKind === 1 /* table */ || importKind === 3 /* global */) { + pos += 2; // skip reftype/valtype + mutability + } else if (importKind === 2 /* memory */) { + const flags = pos < total ? view.getUint8(pos) : 0; pos += 1; + pos += readULEB128(view, pos)[1]; // min + if (flags & 1) pos += readULEB128(view, pos)[1]; // max + } + } + } else if (sectionId === WASM_SECTION.TYPE) { + const [tc] = readULEB128(view, sectionStart); + typeCount = tc; + } else if (sectionId === WASM_SECTION.FUNCTION) { + const [fc] = readULEB128(view, sectionStart); + funcCount = fc; + } else if (sectionId === WASM_SECTION.CUSTOM) { + breakdown.customSections += sectionSize; + // Read custom section name to distinguish debug/name sections + let pos = sectionStart; + const [nameLen, nb] = readULEB128(view, pos); pos += nb; + const name = pos + nameLen <= total ? readString(view, pos, nameLen) : ''; + if (name === 'name' || name.startsWith('.debug') || name === 'producers') { + issues.push({ + type: 'debug-section', + description: `Custom section "${name}" (${sectionSize} bytes) contains debug/metadata info that can be stripped`, + estimatedSavings: sectionSize, + action: 'strip-debug-info', + }); + } + } + + offset = sectionStart + sectionSize; + if (sectionSize === 0 && sectionId === 0) break; // malformed, stop + } + + // Detect unused host function imports: if soroban imports >> funcs used, flag it + // Heuristic: if import section is > 15% of total binary, flag for wasm-opt + if (breakdown.importSection > 0 && breakdown.importSection > total * 0.15) { + issues.push({ + type: 'unused-import', + description: `Import section is ${breakdown.importSection} bytes (${Math.round(breakdown.importSection / total * 100)}% of binary). Run wasm-opt to remove unused host function imports`, + estimatedSavings: Math.floor(breakdown.importSection * 0.3), + action: 'remove-unused-imports', + }); + } + + // Detect unoptimized data segments: data > 40% of total is unusual + if (breakdown.dataSection > 0 && breakdown.dataSection > total * 0.4) { + issues.push({ + type: 'unoptimized-data', + description: `Data section is ${breakdown.dataSection} bytes (${Math.round(breakdown.dataSection / total * 100)}% of binary). Consider using lazy initialization or reducing static data`, + estimatedSavings: Math.floor(breakdown.dataSection * 0.2), + action: 'enable-wasm-opt', + }); + } + + // Detect redundant types: if type count > 2× function count + if (typeCount > 0 && funcCount > 0 && typeCount > funcCount * 2) { + issues.push({ + type: 'redundant-types', + description: `Type section has ${typeCount} entries for ${funcCount} functions. ${typeCount - funcCount} potentially redundant type definitions`, + estimatedSavings: (typeCount - funcCount) * 4, + action: 'enable-wasm-opt', + }); + } + + // If no specific issues but binary is large, recommend wasm-opt generally + if (issues.length === 0 && total > MAX_WASM_SIZE_BYTES * 0.75) { + issues.push({ + type: 'unoptimized-data', + description: `Binary is ${total} bytes (${Math.round(total / MAX_WASM_SIZE_BYTES * 100)}% of limit). Run wasm-opt -Os for general size reduction`, + estimatedSavings: Math.floor(total * 0.1), + action: 'enable-wasm-opt', + }); + } + + const suggestions = [...new Set(issues.map((i) => i.action))] as OptimizationAction[]; + const totalEstimatedSavings = issues.reduce((sum, i) => sum + i.estimatedSavings, 0); + + return { + withinLimit: total <= MAX_WASM_SIZE_BYTES, + sizeBreakdown: breakdown, + issues, + suggestions, + totalEstimatedSavings, + }; +} diff --git a/packages/stellar/src/wasm-optimization.test.ts b/packages/stellar/src/wasm-optimization.test.ts new file mode 100644 index 0000000..84f43fe --- /dev/null +++ b/packages/stellar/src/wasm-optimization.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeWasmOptimization, MAX_WASM_SIZE_BYTES } from './soroban'; + +/** Minimal valid WASM module: magic + version only, no sections. */ +const WASM_MAGIC = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + +function makeWasm(extraBytes = 0): Buffer { + return Buffer.concat([WASM_MAGIC, Buffer.alloc(extraBytes)]); +} + +describe('analyzeWasmOptimization', () => { + it('returns withinLimit true for small binary', () => { + const report = analyzeWasmOptimization(makeWasm(100)); + expect(report.withinLimit).toBe(true); + expect(report.sizeBreakdown.total).toBe(108); + }); + + it('returns withinLimit false when binary exceeds MAX_WASM_SIZE_BYTES', () => { + const report = analyzeWasmOptimization(Buffer.alloc(MAX_WASM_SIZE_BYTES + 1)); + expect(report.withinLimit).toBe(false); + }); + + it('returns empty issues for tiny binary with no sections', () => { + const report = analyzeWasmOptimization(WASM_MAGIC); + expect(report.issues).toHaveLength(0); + expect(report.suggestions).toHaveLength(0); + expect(report.totalEstimatedSavings).toBe(0); + }); + + it('handles non-WASM binary gracefully', () => { + const report = analyzeWasmOptimization(Buffer.from('not wasm')); + expect(report.withinLimit).toBe(true); + expect(report.issues).toHaveLength(0); + }); + + it('handles Uint8Array input', () => { + const bin = new Uint8Array(WASM_MAGIC); + const report = analyzeWasmOptimization(bin); + expect(report.sizeBreakdown.total).toBe(8); + }); + + it('detects debug custom section and suggests strip-debug-info', () => { + // Custom section (id=0) with name "name" + const nameSectionBody = Buffer.from([ + 0x04, // name length = 4 + 0x6e, 0x61, 0x6d, 0x65, // "name" + 0x01, 0x02, 0x03, // some content + ]); + const sectionSize = nameSectionBody.length; + const sectionSizeEncoded = Buffer.from([sectionSize]); // single-byte LEB128 + const wasm = Buffer.concat([ + WASM_MAGIC, + Buffer.from([0x00]), // section id = custom + sectionSizeEncoded, + nameSectionBody, + ]); + const report = analyzeWasmOptimization(wasm); + const debugIssue = report.issues.find((i) => i.type === 'debug-section'); + expect(debugIssue).toBeDefined(); + expect(debugIssue?.action).toBe('strip-debug-info'); + expect(report.suggestions).toContain('strip-debug-info'); + }); + + it('totalEstimatedSavings sums issue savings', () => { + const nameSectionBody = Buffer.from([ + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x01, + ]); + const wasm = Buffer.concat([ + WASM_MAGIC, + Buffer.from([0x00, nameSectionBody.length]), + nameSectionBody, + ]); + const report = analyzeWasmOptimization(wasm); + const expected = report.issues.reduce((s, i) => s + i.estimatedSavings, 0); + expect(report.totalEstimatedSavings).toBe(expected); + }); + + it('suggests enable-wasm-opt for a large binary near the limit', () => { + // Fill a binary that is > 75% of limit with valid WASM header, rest zeros + const size = Math.floor(MAX_WASM_SIZE_BYTES * 0.8); + const wasm = Buffer.concat([WASM_MAGIC, Buffer.alloc(size - 8)]); + const report = analyzeWasmOptimization(wasm); + expect(report.suggestions).toContain('enable-wasm-opt'); + }); + + it('sizeBreakdown.total equals binary length', () => { + const wasm = makeWasm(500); + const report = analyzeWasmOptimization(wasm); + expect(report.sizeBreakdown.total).toBe(wasm.length); + }); + + it('suggestions array has no duplicates', () => { + const wasm = makeWasm(MAX_WASM_SIZE_BYTES - 100); + const report = analyzeWasmOptimization(wasm); + const unique = [...new Set(report.suggestions)]; + expect(report.suggestions).toEqual(unique); + }); +});