diff --git a/.gitignore b/.gitignore index d47756f..565defc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Build output node_modules/ -dist/ +dist +# Generated by scripts/bundle-size-guard.ts (npm run bundle:check) +scripts/bundle-size-report.json # Environment .env diff --git a/package-lock.json b/package-lock.json index 55532b2..f206cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/node": "^25.9.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "esbuild": "^0.27.7", "fake-indexeddb": "^6.2.5", "fast-check": "^4.9.0", "graphql": "^16.14.0", @@ -38,6 +39,7 @@ "size-limit": "^12.1.0", "ts-node": "^10.9.2", "tsup": "^8.5.1", + "tsx": "^4.23.1", "typescript": "^5.9.3", "vitest": "^1.6.1" }, @@ -3432,6 +3434,8 @@ }, "node_modules/esbuild": { "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3839,20 +3843,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -4051,19 +4041,10 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/keyvaluestorage-interface": { "version": "1.0.0", @@ -5351,6 +5332,509 @@ } } }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "license": "Unlicense" @@ -6206,20 +6690,6 @@ } } }, - "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/package.json b/package.json index 8de17b9..32ad21d 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "lint": "tsc --noEmit", "changelog": "vite-node scripts/changelog.ts", "docs": "ts-node scripts/generate-docs.ts", - "size-audit": "tsx scripts/bundle-size-audit.ts" + "size-audit": "tsx scripts/bundle-size-audit.ts", + "bundle:check": "tsx scripts/bundle-size-guard.ts", + "bundle:update-baseline": "BUNDLE_BASELINE_UPDATE=true tsx scripts/bundle-size-guard.ts" }, "dependencies": { "@ledgerhq/hw-app-str": "^6.29.0", @@ -68,6 +70,7 @@ "@types/node": "^25.9.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "esbuild": "^0.27.7", "fake-indexeddb": "^6.2.5", "fast-check": "^4.9.0", "graphql": "^16.14.0", @@ -77,6 +80,7 @@ "size-limit": "^12.1.0", "ts-node": "^10.9.2", "tsup": "^8.5.1", + "tsx": "^4.23.1", "typescript": "^5.9.3", "vitest": "^1.6.1" }, diff --git a/scripts/bundle-size-baseline.json b/scripts/bundle-size-baseline.json new file mode 100644 index 0000000..9feaea4 --- /dev/null +++ b/scripts/bundle-size-baseline.json @@ -0,0 +1,6 @@ +{ + "minifiedBytes": 1558823, + "gzipBytes": 395393, + "entryPoint": "src/index.ts", + "measuredAt": "2026-08-01T18:36:40.236Z" +} diff --git a/scripts/bundle-size-config.ts b/scripts/bundle-size-config.ts new file mode 100644 index 0000000..af380ca --- /dev/null +++ b/scripts/bundle-size-config.ts @@ -0,0 +1,35 @@ +/** + * Bundle size regression guard configuration (issue #586). + * + * Thresholds are applied to the minified-and-gzipped ESM bundle produced from + * `src/index.ts`. Tune these as the SDK grows; update the baseline with: + * + * npm run bundle:update-baseline + */ +export interface BundleSizeConfig { + /** Entry point measured by the guard. */ + entryPoint: string; + /** + * Fail when the gzipped bundle size reaches or exceeds this many bytes. + * Under-threshold bundles pass; exactly-at-threshold fails (a regression + * guard should be strict at the boundary). + */ + maxGzipBytes: number; + /** + * Fail when the gzipped bundle grows by this percentage or more relative to + * the main-branch baseline. + */ + maxGrowthPercent: number; + /** + * Changes smaller than this many gzipped bytes are ignored, preventing + * spurious CI failures on trivial edits. Default per issue: 1 KB. + */ + noiseFloorBytes: number; +} + +export const bundleSizeConfig: BundleSizeConfig = { + entryPoint: "src/index.ts", + maxGzipBytes: 450_000, + maxGrowthPercent: 5, + noiseFloorBytes: 1_024, +}; diff --git a/scripts/bundle-size-guard.ts b/scripts/bundle-size-guard.ts new file mode 100644 index 0000000..06e3450 --- /dev/null +++ b/scripts/bundle-size-guard.ts @@ -0,0 +1,220 @@ +/** + * SDK Bundle Size Regression Guard (issue #586) + * + * Measures the minified and gzipped ESM bundle produced from `src/index.ts` + * and fails the build when the size exceeds a threshold or grows by more than + * an allowed percentage relative to the main-branch baseline. + * + * Usage: + * npm run bundle:check # compare against scripts/bundle-size-baseline.json + * BUNDLE_BASELINE_UPDATE=true npm run bundle:check # (re)write the baseline + * + * The `bundle:update-baseline` npm script is guarded against accidental + * invocation outside CI by requiring the `BUNDLE_BASELINE_UPDATE=true` env + * var. + */ + +import { build } from "esbuild"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { bundleSizeConfig, type BundleSizeConfig } from "./bundle-size-config.js"; + +/** A size measurement of the produced bundle. */ +export interface BundleMeasurement { + minifiedBytes: number; + gzipBytes: number; +} + +/** Baseline persisted to scripts/bundle-size-baseline.json. */ +export interface BundleBaseline extends BundleMeasurement { + entryPoint: string; + measuredAt: string; +} + +/** Verdict produced by {@link evaluateBundle}. */ +export interface BundleSizeVerdict { + pass: boolean; + measured: BundleMeasurement; + baseline: BundleMeasurement | null; + /** Signed delta in gzipped bytes vs baseline (negative = smaller). */ + deltaBytes: number; + /** Signed percentage change vs baseline. */ + deltaPercent: number; + /** Human-readable reasons when the check fails. */ + reasons: string[]; +} + +const CONFIG_DIR = dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = join(CONFIG_DIR, "bundle-size-baseline.json"); +const REPORT_PATH = join(CONFIG_DIR, "bundle-size-report.json"); + +/** + * Pure decision logic, exported for unit tests. + * + * Rules: + * - No baseline ⇒ fail with instructions to regenerate it. + * - `gzipBytes >= maxGzipBytes` ⇒ fail (strict at the boundary). + * - Growth above the noise floor *and* at/over the allowed percentage ⇒ fail. + * - Shrinkage is always fine. + */ +export function evaluateBundle( + measured: BundleMeasurement, + baseline: BundleMeasurement | null, + config: BundleSizeConfig, +): BundleSizeVerdict { + const reasons: string[] = []; + + if (!baseline) { + reasons.push( + `no baseline found — run \`npm run bundle:update-baseline\` to record scripts/bundle-size-baseline.json`, + ); + } + + const deltaBytes = baseline ? measured.gzipBytes - baseline.gzipBytes : 0; + const deltaPercent = + baseline && baseline.gzipBytes > 0 + ? (deltaBytes / baseline.gzipBytes) * 100 + : 0; + + const formatBytes = (bytes: number): string => + bytes.toLocaleString("en-US"); + + if (measured.gzipBytes >= config.maxGzipBytes) { + reasons.push( + `gzipped bundle ${formatBytes(measured.gzipBytes)} B >= max ${formatBytes( + config.maxGzipBytes, + )} B`, + ); + } + + if ( + baseline && + deltaBytes > config.noiseFloorBytes && + deltaPercent >= config.maxGrowthPercent + ) { + reasons.push( + `gzipped bundle grew ${formatBytes(deltaBytes)} B (${deltaPercent.toFixed( + 2, + )}%) — exceeds noise floor ${formatBytes( + config.noiseFloorBytes, + )} B and ${config.maxGrowthPercent}% allowance`, + ); + } + + return { + pass: reasons.length === 0, + measured, + baseline, + deltaBytes, + deltaPercent, + reasons, + }; +} + +/** Bundles the entry point with esbuild and measures minified + gzipped sizes. */ +export async function measureBundle( + entryPoint: string, +): Promise { + const result = await build({ + entryPoints: [entryPoint], + bundle: true, + format: "esm", + // Node platform mirrors the real `npm run build` (tsup) output: Node + // builtins such as `crypto`/`fs` stay external and are not counted, so the + // measurement reflects the SDK's own payload plus bundled dependencies. + platform: "node", + target: "es2020", + minify: true, + write: false, + logLevel: "error", + }); + const minifiedBytes = result.outputFiles[0]!.contents.byteLength; + const gzipBytes = gzipSync(result.outputFiles[0]!.contents).byteLength; + return { minifiedBytes, gzipBytes }; +} + +function loadBaseline(): BundleMeasurement | null { + if (!existsSync(BASELINE_PATH)) return null; + const parsed = JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as BundleBaseline; + return { minifiedBytes: parsed.minifiedBytes, gzipBytes: parsed.gzipBytes }; +} + +function writeBaseline(measured: BundleMeasurement): void { + const baseline: BundleBaseline = { + ...measured, + entryPoint: bundleSizeConfig.entryPoint, + measuredAt: new Date().toISOString(), + }; + writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`); + console.log(`✅ baseline written to ${BASELINE_PATH}`); +} + +function formatBytes(bytes: number): string { + return `${bytes.toLocaleString("en-US")} B`; +} + +function printReport(verdict: BundleSizeVerdict): void { + console.log("📦 SDK Bundle Size Guard\n"); + console.log(` minified : ${formatBytes(verdict.measured.minifiedBytes)}`); + console.log(` gzipped : ${formatBytes(verdict.measured.gzipBytes)}`); + + if (verdict.baseline) { + const sign = verdict.deltaBytes >= 0 ? "+" : ""; + console.log( + ` baseline : ${formatBytes(verdict.baseline.gzipBytes)} (delta ${sign}${formatBytes( + verdict.deltaBytes, + )} / ${sign}${verdict.deltaPercent.toFixed(2)}%)`, + ); + } else { + console.log(" baseline : "); + } + + writeFileSync( + REPORT_PATH, + `${JSON.stringify(verdict, null, 2)}\n`, + ); + console.log(`\n report : ${REPORT_PATH}`); + + if (verdict.pass) { + console.log("\n✅ Bundle size within threshold"); + } else { + console.log("\n❌ Bundle size guard failed:"); + for (const reason of verdict.reasons) { + console.log(` - ${reason}`); + } + } +} + +async function main(): Promise { + // Baseline updates are guarded against accidental invocation: the baseline is + // only (re)written when BUNDLE_BASELINE_UPDATE=true is explicitly set (see + // the `bundle:update-baseline` npm script). + const updateBaseline = process.env.BUNDLE_BASELINE_UPDATE === "true"; + + const measured = await measureBundle(bundleSizeConfig.entryPoint); + + if (updateBaseline) { + writeBaseline(measured); + process.exit(0); + } + + const verdict = evaluateBundle( + measured, + loadBaseline(), + bundleSizeConfig, + ); + printReport(verdict); + process.exit(verdict.pass ? 0 : 1); +} + +const isMain = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMain) { + main().catch((error) => { + console.error("❌ bundle size guard failed with error:", error); + process.exit(1); + }); +} diff --git a/src/client.ts b/src/client.ts index 62db3db..7b56024 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,6 +17,7 @@ import { Keypair, } from "@stellar/stellar-sdk"; import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import type { Signer } from "./signing/signer.js"; import type { CircuitStateChangeLogEvent } from "./resilience/CircuitBreaker.js"; import { InvoiceStateMachine } from "./state/InvoiceStateMachine.js"; import type { StateMachineConfig } from "./types/state.js"; @@ -81,6 +82,7 @@ import { resolveToken } from "./token.js"; import { generatePaymentReceipt } from "./receipt.js"; import type { PaymentReceipt } from "./receipt.js"; import { checkInvoiceExpiry, checkPayerReadiness } from "./preflightChecker.js"; +import { InvoiceCloneabilityValidator } from "./preflight/InvoiceCloneabilityValidator.js"; import { createInvoiceSubscription } from "./subscription.js"; import type { Subscription, InvoiceEvent, SubscriptionOptions } from "./types.js"; import { getSubscriptionManager } from "./streaming/SubscriptionManager.js"; @@ -200,6 +202,7 @@ import { AdminOperationError, PassphraseMismatchError, InvoiceIntegrityError, + InvoiceNotCloneableError, InvalidTransactionTypeError, } from "./errors.js"; import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; @@ -324,6 +327,14 @@ export interface StellarSplitClientConfig { cache?: { enabled?: boolean; ttl?: Record; ttlMs?: number }; /** Optional signing keypair for request signing. */ signingKeypair?: Keypair; + /** + * Optional pluggable signing vault adapter (issue #589). When provided, + * transaction signing can be delegated to a hardware security module, + * cloud KMS, or encrypted keystore through the narrow {@link Signer} + * contract instead of an in-memory {@link Keypair}. Exposed at runtime via + * `client.signer`. + */ + signer?: Signer; /** Optional compliance rules injectable for invoice checks. */ complianceRules?: import("./compliance.js").ComplianceRule[]; /** Optional dependency injection container for RPC, cache, and wallet implementations. */ @@ -652,6 +663,9 @@ export class StellarSplitClient extends TypedEventEmitter { private _activeTransportType: TransportType = 'http'; private _fallbackListeners: Array<(event: { from: 'websocket'; to: 'http' }) => void> = []; /** Admin keypair used to sign admin-only operations (freeze/unfreeze). */ + /** Pluggable signing vault adapter (issue #589). */ + private _signer: Signer | null = null; + /** Admin keypair used to sign admin-only operations (freeze/unfreeze). */ private _adminKeypair: Keypair | null = null; /** Resilient RPC wrapper providing retry + circuit breaker for all RPC calls. */ private _resilientRpc: ResilientRpcClient | null = null; @@ -909,6 +923,9 @@ export class StellarSplitClient extends TypedEventEmitter { }); } + // Pluggable signing vault adapter (issue #589) + this._signer = config.signer ?? null; + // Admin keypair for admin-only operations if (config.adminKeypair) { this._adminKeypair = config.adminKeypair; @@ -1092,6 +1109,14 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * The pluggable signing vault adapter (issue #589) provided at construction, + * or `null` when the client was created without one. + */ + get signer(): Signer | null { + return this._signer; + } + /** * Wraps every public StellarSplitClient method (every own, non-underscore- * prefixed function on the prototype) so that calling it opens an OTel diff --git a/src/errors.ts b/src/errors.ts index 7b09f53..bed7387 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1807,3 +1807,180 @@ export class DataEntryValidationError extends StellarSplitError { export function isDataEntryValidationError(err: unknown): err is DataEntryValidationError { return err instanceof DataEntryValidationError; } + +// --------------------------------------------------------------------------- +// Preflight / fee-bump / integrity errors (consumed by preflightChecker, +// feeBumpBuilder, splitRollbackCoordinator, invoiceMetadataValidator, etc.) +// --------------------------------------------------------------------------- + +/** Thrown when an invoice has already expired at payment-preflight time. */ +export class PaymentExpiredError extends StellarSplitError { + readonly invoiceId: string; + readonly expiresAt: number; + + constructor(invoiceId: string, expiresAt: number) { + super(`Invoice ${invoiceId} expired at ${expiresAt}`, "PAYMENT_EXPIRED", { + invoiceId, + expiresAt, + }); + this.name = "PaymentExpiredError"; + this.invoiceId = invoiceId; + this.expiresAt = expiresAt; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when a sponsor account lacks the reserve to cover new ledger entries. */ +export class InsufficientSponsorReserveError extends StellarSplitError { + readonly sponsorAddress: string; + readonly availableStroops: bigint; + readonly requiredStroops: bigint; + readonly newEntryCount: number; + + constructor( + sponsorAddress: string, + availableStroops: bigint, + requiredStroops: bigint, + newEntryCount: number, + ) { + super( + `Sponsor ${sponsorAddress} has insufficient reserve: ${availableStroops} available, ${requiredStroops} required for ${newEntryCount} new entries`, + "INSUFFICIENT_SPONSOR_RESERVE", + { sponsorAddress, availableStroops: availableStroops.toString(), requiredStroops: requiredStroops.toString(), newEntryCount }, + ); + this.name = "InsufficientSponsorReserveError"; + this.sponsorAddress = sponsorAddress; + this.availableStroops = availableStroops; + this.requiredStroops = requiredStroops; + this.newEntryCount = newEntryCount; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when a fee-bump inner transaction is not a v1 transaction. */ +export class InvalidTransactionTypeError extends StellarSplitError { + readonly typeName: string; + + constructor(typeName: string) { + super(`Invalid transaction type: ${typeName}`, "INVALID_TRANSACTION_TYPE", { + typeName, + }); + this.name = "InvalidTransactionTypeError"; + this.typeName = typeName; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when a rollback is requested for an unknown split id/leg. */ +export class UnknownSplitError extends StellarSplitError { + readonly splitId: string; + + constructor(splitId: string) { + super(`Unknown split: ${splitId}`, "UNKNOWN_SPLIT", { splitId }); + this.name = "UnknownSplitError"; + this.splitId = splitId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when invoice metadata fails validation (ajv-style error list). */ +export class MetadataValidationError extends StellarSplitError { + readonly errors: unknown[]; + + constructor(errors: unknown[]) { + super( + `Invoice metadata validation failed with ${errors.length} error(s)`, + "METADATA_VALIDATION", + { count: errors.length }, + ); + this.name = "MetadataValidationError"; + this.errors = errors; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when an invoice's content hash diverges from its declared hash. */ +export class InvoiceIntegrityError extends StellarSplitError { + readonly invoiceId: string; + readonly expectedHash: string; + readonly computedHash: string; + + constructor(invoiceId: string, expectedHash: string, computedHash: string) { + super( + `Invoice ${invoiceId} integrity check failed: declared hash ${expectedHash} does not match computed ${computedHash}`, + "INVOICE_INTEGRITY", + { invoiceId, expectedHash, computedHash }, + ); + this.name = "InvoiceIntegrityError"; + this.invoiceId = invoiceId; + this.expectedHash = expectedHash; + this.computedHash = computedHash; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when an approval workflow exceeds its configured timeout. */ +export class ApprovalTimeoutError extends StellarSplitError { + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Approval workflow timed out after ${timeoutMs} ms`, "APPROVAL_TIMEOUT", { + timeoutMs, + }); + this.name = "ApprovalTimeoutError"; + this.timeoutMs = timeoutMs; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isApprovalTimeoutError(err: unknown): err is ApprovalTimeoutError { + return err instanceof ApprovalTimeoutError; +} + +/** Thrown when a transaction is not confirmed within the finality wait budget. */ +export class FinalityTimeoutError extends StellarSplitError { + readonly txHash: string; + readonly maxWaitMs: number; + + constructor(txHash: string, maxWaitMs: number) { + super( + `Transaction ${txHash} not confirmed within ${maxWaitMs} ms`, + "FINALITY_TIMEOUT", + { txHash, maxWaitMs }, + ); + this.name = "FinalityTimeoutError"; + this.txHash = txHash; + this.maxWaitMs = maxWaitMs; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isFinalityTimeoutError(err: unknown): err is FinalityTimeoutError { + return err instanceof FinalityTimeoutError; +} + +/** Thrown when a path query is malformed (missing/invalid parameters). */ +export class InvalidPathQueryError extends StellarSplitError { + readonly context: Record | undefined; + + constructor(message: string, context?: Record) { + super(message, "INVALID_PATH_QUERY", context); + this.name = "InvalidPathQueryError"; + this.context = context; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when an invoice cannot be cloned (cloneability pre-flight failed). */ +export class InvoiceNotCloneableError extends StellarSplitError { + readonly report: import("./preflight/InvoiceCloneabilityValidator.js").CloneabilityReport; + + constructor(report: import("./preflight/InvoiceCloneabilityValidator.js").CloneabilityReport) { + super("Invoice is not cloneable", "INVOICE_NOT_CLONEABLE", { + fieldReports: report.fieldReports, + }); + this.name = "InvoiceNotCloneableError"; + this.report = report; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index 96e31af..2fe2b4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -279,7 +279,6 @@ export { TxQueue } from "./queue.js"; export { replayEvents } from "./events.js"; export { sdkEvents } from "./events.js"; -export { FinalityChecker } from "./finalityChecker.js"; export type { FinalityServerLike } from "./finalityChecker.js"; export { ApprovalWorkflowSequencer, ApprovalSession } from "./approvalWorkflowSequencer.js"; export type { ApprovalWorkflowOptions, NotificationAdapter, SignatureApplier } from "./approvalWorkflowSequencer.js"; @@ -921,8 +920,7 @@ export { PredicateBuilder } from "./predicateBuilder.js"; export type { ClaimPredicate } from "./predicateBuilder.js"; export type { PredicateConfig } from "./types.js"; -export { RateCache } from "./rateCache.js"; -export type { RateCacheEntry, RateOracleFn, RateCacheConfig } from "./rateCache.js"; +export type { RateCacheEntry, RateOracleFn } from "./rateCache.js"; export { subscribeToInvoice } from "./sse.js"; export type { @@ -1334,3 +1332,37 @@ export { exportSplitAuditTrail, SPLIT_AUDIT_CSV_COLUMNS, } from "./complianceExporter.js"; + +// --------------------------------------------------------------------------- +// #589 — Pluggable Signing Key Vault Adapter +// --------------------------------------------------------------------------- + +export type { Signer } from "./signing/signer.js"; +export { KeypairSigner } from "./signing/adapters/KeypairSigner.js"; +export { EncryptedFileSigner } from "./signing/adapters/EncryptedFileSigner.js"; +export type { + EncryptedFileSignerOptions, +} from "./signing/adapters/EncryptedFileSigner.js"; +export { + encryptSigningKeyToPem, + writeEncryptedSigningKeyFile, +} from "./signing/adapters/EncryptedFileSigner.js"; +export { CloudKmsSigner } from "./signing/adapters/CloudKmsSigner.js"; +export type { KmsClient } from "./signing/adapters/CloudKmsSigner.js"; + +// --------------------------------------------------------------------------- +// #588 — Soroban Transaction Footprint Optimizer +// --------------------------------------------------------------------------- + +export { optimizeFootprint } from "./soroban/footprint.js"; +export type { + OptimizeFootprintOptions, + FootprintLogger, +} from "./soroban/footprint.js"; +export { footprintDiff } from "./utils/footprintDiff.js"; +export type { FootprintDiff } from "./utils/footprintDiff.js"; +export { submitTransaction } from "./transaction/submit.js"; +export type { + SubmitTransactionOptions, + SubmitServer, +} from "./transaction/submit.js"; diff --git a/src/signing/adapters/CloudKmsSigner.ts b/src/signing/adapters/CloudKmsSigner.ts new file mode 100644 index 0000000..0583904 --- /dev/null +++ b/src/signing/adapters/CloudKmsSigner.ts @@ -0,0 +1,38 @@ +import type { Signer } from "../signer.js"; + +/** + * Minimal client contract for a cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key + * Vault, HashiCorp Vault, ...). Implementations are intentionally left to the + * caller so the SDK carries no vendor SDK dependency and tests can mock the + * client without touching real KMS credentials. + */ +export interface KmsClient { + /** + * Sign `digest` (typically a 32-byte transaction hash) with the key + * identified by `keyId`, returning the raw signature bytes. + */ + sign(keyId: string, digest: Buffer): Promise; +} + +/** + * {@link Signer} that delegates signing to an injected {@link KmsClient}. + * + * @example + * ```ts + * const signer = new CloudKmsSigner(awsKmsClient, "alias/split-signing-key"); + * const signature = await signer.sign(txHash); + * ``` + */ +export class CloudKmsSigner implements Signer { + readonly kmsClient: KmsClient; + readonly keyId: string; + + constructor(kmsClient: KmsClient, keyId: string) { + this.kmsClient = kmsClient; + this.keyId = keyId; + } + + async sign(txHash: Buffer): Promise { + return this.kmsClient.sign(this.keyId, txHash); + } +} diff --git a/src/signing/adapters/EncryptedFileSigner.ts b/src/signing/adapters/EncryptedFileSigner.ts new file mode 100644 index 0000000..07307d7 --- /dev/null +++ b/src/signing/adapters/EncryptedFileSigner.ts @@ -0,0 +1,157 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { Keypair } from "@stellar/stellar-sdk"; +import type { Signer } from "../signer.js"; + +/** + * Options for {@link EncryptedFileSigner}. + */ +export interface EncryptedFileSignerOptions { + /** + * AES-256 key (exactly 32 bytes) used to decrypt the key file. + * In production this should be derived from a passphrase or fetched from a + * secrets manager; it must never be stored next to the encrypted file. + */ + aesKey: Buffer | Uint8Array; +} + +const BEGIN_BLOCK = "-----BEGIN SPLIT ENCRYPTED SIGNING KEY-----"; +const END_BLOCK = "-----END SPLIT ENCRYPTED SIGNING KEY-----"; + +const SECRET_BEGIN = "-----BEGIN STELLAR SIGNING KEY-----"; +const SECRET_END = "-----END STELLAR SIGNING KEY-----"; + +const GCM_IV_LENGTH = 12; +const GCM_TAG_LENGTH = 16; + +/** + * Encrypts a Stellar secret seed (`S...` string) with AES-256-GCM and returns + * a PEM-style payload suitable for {@link writeEncryptedSigningKeyFile}. + * + * The payload is `iv || authTag || ciphertext` base64-encoded inside PEM + * markers. The plaintext is itself a PEM block containing the seed, so the + * file can be inspected without ever exposing the raw key material. + */ +export function encryptSigningKeyToPem(secret: string, aesKey: Buffer | Uint8Array): string { + const key = Buffer.from(aesKey); + if (key.length !== 32) { + throw new Error("EncryptedFileSigner: aesKey must be exactly 32 bytes"); + } + const iv = randomBytes(GCM_IV_LENGTH); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const plaintext = Buffer.from(`${SECRET_BEGIN}\n${secret}\n${SECRET_END}\n`, "utf8"); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const payload = Buffer.concat([Buffer.from(iv), cipher.getAuthTag(), ciphertext]); + return `${BEGIN_BLOCK}\n${payload.toString("base64").replace(/(.{64})/g, "$1\n").trim()}\n${END_BLOCK}\n`; +} + +/** + * Writes an AES-256-GCM encrypted PEM key file to `filePath` for the given + * secret seed. Test helper and convenience for tooling that provisions + * encrypted keystores. + */ +export async function writeEncryptedSigningKeyFile( + filePath: string, + secret: string, + aesKey: Buffer | Uint8Array, +): Promise { + await writeFile(filePath, encryptSigningKeyToPem(secret, aesKey), "utf8"); +} + +/** + * {@link Signer} that reads an AES-256-GCM encrypted PEM key file, decrypts it + * on first use, and holds the derived {@link Keypair} in a {@link WeakRef} so + * GC pressure can clear it. If the cached keypair has been collected (or + * {@link clearCache} was called), the next `sign` call re-reads the file. + */ +export class EncryptedFileSigner implements Signer { + readonly filePath: string; + private readonly aesKey: Buffer; + /** Weak reference to the decrypted keypair — cleared by GC or clearCache(). */ + private cachedKeypairRef: WeakRef | null = null; + + constructor(filePath: string, options: EncryptedFileSignerOptions) { + this.filePath = filePath; + this.aesKey = Buffer.from(options.aesKey); + if (this.aesKey.length !== 32) { + throw new Error("EncryptedFileSigner: aesKey must be exactly 32 bytes"); + } + } + + /** + * Drops the cached keypair so the next {@link sign} call re-reads and + * re-decrypts the key file. Useful after key rotation, and mirrors the + * behavior of the WeakRef being collected under GC pressure. + */ + clearCache(): void { + this.cachedKeypairRef = null; + } + + async sign(txHash: Buffer): Promise { + const keypair = await this._getKeypair(); + return Buffer.from(keypair.sign(txHash)); + } + + private async _getKeypair(): Promise { + const cached = this.cachedKeypairRef?.deref(); + if (cached) return cached; + // Re-read + decrypt on first use / after GC. The keypair is kept alive + // for the duration of this call even though only a WeakRef is stored. + const keypair = await this._loadKeypair(); + this.cachedKeypairRef = new WeakRef(keypair); + return keypair; + } + + private async _loadKeypair(): Promise { + const content = await readFile(this.filePath, "utf8"); + const { iv, authTag, ciphertext } = parseEncryptedPayload(content); + const decipher = createDecipheriv("aes-256-gcm", this.aesKey, iv); + decipher.setAuthTag(authTag); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + const secret = extractSecretFromPem(plaintext.toString("utf8")); + return Keypair.fromSecret(secret); + } +} + +function parseEncryptedPayload(content: string): { + iv: Buffer; + authTag: Buffer; + ciphertext: Buffer; +} { + const match = content.match( + new RegExp(`${escapeRegExp(BEGIN_BLOCK)}\\s*([A-Za-z0-9+/=\\s]+?)\\s*${escapeRegExp(END_BLOCK)}`), + ); + if (!match?.[1]) { + throw new Error("EncryptedFileSigner: file does not contain a valid SPLIT ENCRYPTED SIGNING KEY block"); + } + const payload = Buffer.from(match[1].replace(/\s+/g, ""), "base64"); + if (payload.length < GCM_IV_LENGTH + GCM_TAG_LENGTH + 1) { + throw new Error("EncryptedFileSigner: encrypted payload is truncated"); + } + return { + iv: payload.subarray(0, GCM_IV_LENGTH), + authTag: payload.subarray(GCM_IV_LENGTH, GCM_IV_LENGTH + GCM_TAG_LENGTH), + ciphertext: payload.subarray(GCM_IV_LENGTH + GCM_TAG_LENGTH), + }; +} + +function extractSecretFromPem(plaintext: string): string { + const match = plaintext.match( + new RegExp(`${escapeRegExp(SECRET_BEGIN)}\\s*([A-Za-z0-9]+)\\s*${escapeRegExp(SECRET_END)}`), + ); + if (!match?.[1]) { + throw new Error("EncryptedFileSigner: decrypted payload does not contain a STELLAR SIGNING KEY block"); + } + const secret = match[1].trim(); + // Validate eagerly so a wrong-key decrypt (garbage plaintext) surfaces as a + // clear error rather than a confusing downstream failure. + Keypair.fromSecret(secret); + return secret; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/signing/adapters/KeypairSigner.ts b/src/signing/adapters/KeypairSigner.ts new file mode 100644 index 0000000..e795a69 --- /dev/null +++ b/src/signing/adapters/KeypairSigner.ts @@ -0,0 +1,26 @@ +import { Keypair } from "@stellar/stellar-sdk"; +import type { Signer } from "../signer.js"; + +/** + * {@link Signer} backed by an in-memory {@link Keypair} from + * `@stellar/stellar-sdk`. + * + * Useful for local development and for the common case where the secret seed + * already lives in the process (e.g. loaded from an environment variable). + */ +export class KeypairSigner implements Signer { + /** The wrapped keypair. */ + readonly keypair: Keypair; + + constructor(keypair: Keypair) { + this.keypair = keypair; + } + + /** + * Signs `txHash` with the wrapped keypair, producing a raw 64-byte ed25519 + * signature verifiable by `Keypair.verify(txHash, signature, publicKey)`. + */ + async sign(txHash: Buffer): Promise { + return Buffer.from(this.keypair.sign(txHash)); + } +} diff --git a/src/signing/signer.ts b/src/signing/signer.ts new file mode 100644 index 0000000..e607799 --- /dev/null +++ b/src/signing/signer.ts @@ -0,0 +1,20 @@ +/** + * Pluggable Signing Key Vault Adapter (issue #589) + * + * A narrow contract that decouples transaction signing from key storage so + * callers can inject any backend conforming to {@link Signer} — a hardware + * security module, a cloud KMS, or an AES-encrypted keystore — without + * modifying SDK internals. + */ + +/** + * Produces an ed25519 signature over a transaction hash. + * + * The input is the raw 32-byte transaction hash (the same bytes produced by + * `Transaction.hash()` / `signatureBase()`); the returned value is the raw + * 64-byte ed25519 signature that can be verified with + * `Keypair.verify(txHash, signature)` against the corresponding public key. + */ +export interface Signer { + sign(txHash: Buffer): Promise; +} diff --git a/src/snapshot.ts b/src/snapshot.ts index b75b74d..ed2e6f6 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -1,5 +1,10 @@ import { createHash } from "crypto"; -import type { Invoice, Payment, ReminderSchedule } from "./types.js"; +import type { + Invoice, + Payment, + ReminderSchedule, + SplitRollbackCheckpoint, +} from "./types.js"; export interface InvoiceSnapshot { snapshotId: string; @@ -71,3 +76,58 @@ export async function saveDedupTokens(namespace: string, tokens: string[]): Prom export async function loadDedupTokens(namespace: string): Promise { return defaultDedupTokenStore.load(namespace); } + +// --------------------------------------------------------------------------- +// Reminder schedule persistence (InvoiceReminderScheduler) +// --------------------------------------------------------------------------- + +let reminderSchedulesStore: ReminderSchedule[] = []; + +/** + * Load the persisted reminder schedules (in-memory singleton, reset per + * process). Callers receive a defensive copy. + */ +export function loadReminderSchedules(): ReminderSchedule[] { + return reminderSchedulesStore.map((s) => ({ ...s })); +} + +/** Persist the reminder schedules (in-memory singleton). */ +export function saveReminderSchedules(schedules: ReminderSchedule[]): void { + reminderSchedulesStore = schedules.map((s) => ({ ...s })); +} + +// --------------------------------------------------------------------------- +// Split rollback checkpoint snapshots (SplitRollbackCoordinator) +// --------------------------------------------------------------------------- + +/** Immutable snapshot of a split rollback checkpoint. */ +export interface SplitRollbackRecord { + /** SHA-256 identifier derived from the checkpoint contents + capture time. */ + snapshotId: string; + /** Unix epoch ms when the snapshot was captured. */ + capturedAt: number; + /** The frozen checkpoint this record snapshots. */ + checkpoint: Readonly; +} + +/** + * Captures an immutable snapshot of a split rollback checkpoint so the + * coordinator can prove, audit, and idempotently replay rollback state. + */ +export function snapshotSplitRollback( + checkpoint: SplitRollbackCheckpoint, +): SplitRollbackRecord { + const capturedAt = Date.now(); + const snapshotId = createHash("sha256") + .update(`${checkpoint.splitId}${checkpoint.invoiceId}${capturedAt}`) + .digest("hex"); + + return Object.freeze({ + snapshotId, + capturedAt, + checkpoint: Object.freeze({ + ...checkpoint, + legs: Object.freeze([...checkpoint.legs]), + }), + }); +} diff --git a/src/soroban/footprint.ts b/src/soroban/footprint.ts new file mode 100644 index 0000000..47ccd66 --- /dev/null +++ b/src/soroban/footprint.ts @@ -0,0 +1,112 @@ +import { + rpc as SorobanRpc, + SorobanDataBuilder, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { footprintDiff } from "../utils/footprintDiff.js"; + +/** Minimal logger surface used by the optimizer for removed-key diagnostics. */ +export interface FootprintLogger { + debug(message: string): void; +} + +const noopLogger: FootprintLogger = { debug: () => undefined }; + +export interface OptimizeFootprintOptions { + /** Receives a `debug`-level line for every ledger key pruned from the footprint. */ + logger?: FootprintLogger; +} + +/** + * Replaces a Soroban transaction's declared ledger-key footprint with the + * minimal read/write key set reported by `simulateTransaction`, pruning + * stale or overly broad keys that inflate inclusion fees. + * + * Reconstruction is done through `SorobanDataBuilder` semantics: the minimal + * `SorobanTransactionData` from the simulation response is applied to a clone + * of the original transaction (via `TransactionBuilder.cloneFrom`, the same + * mechanism `assembleTransaction` uses), so all other fields — operations, + * source, memo, time bounds, fee — are preserved. + * + * Each ledger key present in the original footprint but absent from the + * simulation result is logged at debug level and dropped. + * + * @param tx The Soroban transaction whose footprint should be optimized. + * @param sim The successful simulation response carrying the minimal footprint. + * @returns A new transaction with the trimmed footprint. The input is not + * mutated. + */ +export function optimizeFootprint( + tx: Transaction, + sim: SorobanRpc.Api.SimulateTransactionSuccessResponse, + options: OptimizeFootprintOptions = {}, +): Transaction { + const logger = options.logger ?? noopLogger; + + const simData = toSorobanDataBuilder(sim.transactionData).build(); + const minimalReadOnly = simData.resources().footprint().readOnly(); + const minimalReadWrite = simData.resources().footprint().readWrite(); + + const originalData = readSorobanData(tx); + if (originalData) { + const originalKeys = [ + ...originalData.resources().footprint().readOnly(), + ...originalData.resources().footprint().readWrite(), + ]; + const diff = footprintDiff(originalKeys, [ + ...minimalReadOnly, + ...minimalReadWrite, + ]); + for (const key of diff.removed) { + logger.debug( + `[footprint] removing surplus ledger key ${key.toXDR("base64")}`, + ); + } + } + + return TransactionBuilder.cloneFrom(tx, { + // Preserve the original classic fee explicitly — cloneFrom only copies it + // when not overridden, and this guarantees byte-identical output for an + // already-minimal footprint. + fee: tx.fee, + networkPassphrase: tx.networkPassphrase, + sorobanData: simData, + }).build(); +} + +/** + * Normalizes `sim.transactionData` (a `SorobanDataBuilder` for parsed success + * responses) into a builder. Raw string payloads are parsed defensively. + */ +function toSorobanDataBuilder( + transactionData: SorobanDataBuilder | string, +): SorobanDataBuilder { + if (typeof transactionData === "string") { + return new SorobanDataBuilder(transactionData); + } + return transactionData; +} + +/** + * Reads the declared `SorobanTransactionData` off a transaction envelope, or + * `undefined` for classic (non-Soroban) transactions. + * + * The soroban data lives on the transaction envelope's `TransactionV1.ext` + * arm — the same location the protocol uses for `SorobanTransactionData`. + */ +function readSorobanData(tx: Transaction): xdr.SorobanTransactionData | undefined { + const envelope = tx.toEnvelope(); + const v1 = envelope.v1(); + // Classic (pre-protocol-13 / V0) envelopes have no V1 arm; there is nothing + // to optimize or diff against. + if (!v1) return undefined; + const txV1 = v1.tx(); + const ext = txV1.ext(); + if (typeof ext.sorobanData === "function") { + const data = ext.sorobanData(); + if (data) return data; + } + return undefined; +} diff --git a/src/transaction/submit.ts b/src/transaction/submit.ts new file mode 100644 index 0000000..7115bfe --- /dev/null +++ b/src/transaction/submit.ts @@ -0,0 +1,49 @@ +import { rpc as SorobanRpc, Transaction } from "@stellar/stellar-sdk"; +import { + FootprintLogger, + optimizeFootprint, +} from "../soroban/footprint.js"; + +/** The minimal RPC surface needed to submit a transaction. */ +export type SubmitServer = { + submitTransaction( + tx: Transaction | string, + opts?: Record, + ): Promise; +}; + +export interface SubmitTransactionOptions { + /** + * When true (default), the transaction's declared footprint is trimmed to + * the minimal key set reported by the simulation result before submission. + * Pass `{ optimizeFootprint: false }` to submit the transaction exactly as + * built. + */ + optimizeFootprint?: boolean; + /** Receives debug-level diagnostics for keys pruned by the optimizer. */ + logger?: FootprintLogger; +} + +/** + * Submits a Soroban transaction through the given server, running the + * footprint optimizer (issue #588) immediately before submission unless it is + * explicitly disabled. + * + * @param server The RPC server (or mock) used to submit the transaction. + * @param tx The (prepared) Soroban transaction to submit. + * @param sim The successful simulation response for `tx`; provides the + * minimal footprint used by the optimizer. + * @returns The send-transaction response. + */ +export async function submitTransaction( + server: SubmitServer, + tx: Transaction, + sim: SorobanRpc.Api.SimulateTransactionSuccessResponse, + options: SubmitTransactionOptions = {}, +): Promise { + const optimize = options.optimizeFootprint !== false; + const toSubmit = optimize + ? optimizeFootprint(tx, sim, { logger: options.logger }) + : tx; + return server.submitTransaction(toSubmit); +} diff --git a/src/utils/footprintDiff.ts b/src/utils/footprintDiff.ts new file mode 100644 index 0000000..9e9813d --- /dev/null +++ b/src/utils/footprintDiff.ts @@ -0,0 +1,66 @@ +import { xdr } from "@stellar/stellar-sdk"; + +/** + * Result of classifying two footprint key sets against each other. + */ +export interface FootprintDiff { + /** Keys present in the minimal (simulation) set but absent from the original. */ + added: xdr.LedgerKey[]; + /** Keys present in the original footprint but absent from the minimal set. */ + removed: xdr.LedgerKey[]; + /** Keys present in both sets. */ + unchanged: xdr.LedgerKey[]; +} + +/** + * Classifies the difference between an original declared footprint and the + * minimal key set reported by a simulation. + * + * `original` is the set of ledger keys currently declared on the transaction + * (read-only + read-write combined); `minimal` is the trimmed set reported by + * `simulateTransaction`. Ledger keys are compared by their canonical base64 + * XDR encoding so structurally identical keys from different builders are + * treated as the same key. + * + * @example + * ```ts + * const { added, removed, unchanged } = footprintDiff( + * [keyA, keyB], + * [keyB, keyC], + * ); + * // removed === [keyA], added === [keyC], unchanged === [keyB] + * ``` + */ +export function footprintDiff( + original: xdr.LedgerKey[], + minimal: xdr.LedgerKey[], +): FootprintDiff { + const originalByEncoding = new Map(); + for (const key of original) { + originalByEncoding.set(key.toXDR("base64"), key); + } + + const minimalByEncoding = new Map(); + for (const key of minimal) { + minimalByEncoding.set(key.toXDR("base64"), key); + } + + const added: xdr.LedgerKey[] = []; + const removed: xdr.LedgerKey[] = []; + const unchanged: xdr.LedgerKey[] = []; + + for (const [encoding, key] of originalByEncoding) { + if (minimalByEncoding.has(encoding)) { + unchanged.push(key); + } else { + removed.push(key); + } + } + for (const [encoding, key] of minimalByEncoding) { + if (!originalByEncoding.has(encoding)) { + added.push(key); + } + } + + return { added, removed, unchanged }; +} diff --git a/test/bundleSizeGuard.test.ts b/test/bundleSizeGuard.test.ts new file mode 100644 index 0000000..cfe8763 --- /dev/null +++ b/test/bundleSizeGuard.test.ts @@ -0,0 +1,113 @@ +// esbuild refuses to load under jsdom's TextEncoder (Uint8Array instance +// check), so this suite runs in the node environment. +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + evaluateBundle, + type BundleSizeConfig, +} from "../scripts/bundle-size-guard.js"; + +const CONFIG: BundleSizeConfig = { + entryPoint: "src/index.ts", + maxGzipBytes: 100_000, + maxGrowthPercent: 5, + noiseFloorBytes: 1_024, +}; + +const baseline = { minifiedBytes: 500_000, gzipBytes: 90_000 }; + +function exitCodeOf(pass: boolean): number { + return pass ? 0 : 1; +} + +describe("evaluateBundle (bundle:check decision logic)", () => { + it("passes a bundle under the size threshold", () => { + const verdict = evaluateBundle( + { minifiedBytes: 480_000, gzipBytes: 89_500 }, + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(true); + expect(exitCodeOf(verdict.pass)).toBe(0); + expect(verdict.reasons).toEqual([]); + }); + + it("fails a bundle exactly at the threshold (strict boundary)", () => { + const verdict = evaluateBundle( + { minifiedBytes: 500_000, gzipBytes: 100_000 }, + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(false); + expect(exitCodeOf(verdict.pass)).toBe(1); + expect(verdict.reasons.some((r) => r.includes("max 100,000 B"))).toBe(true); + }); + + it("fails a bundle over the threshold", () => { + const verdict = evaluateBundle( + { minifiedBytes: 600_000, gzipBytes: 100_001 }, + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(false); + expect(exitCodeOf(verdict.pass)).toBe(1); + }); + + it("ignores growth below the noise floor even when the percentage is large", () => { + const verdict = evaluateBundle( + { minifiedBytes: 500_000, gzipBytes: 90_500 }, // +500 B, well under 1 KB + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(true); + expect(exitCodeOf(verdict.pass)).toBe(0); + }); + + it("fails when growth exceeds the noise floor and the allowed percentage", () => { + const verdict = evaluateBundle( + { minifiedBytes: 500_000, gzipBytes: 95_000 }, // +5 KB = +5.56% + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(false); + expect(exitCodeOf(verdict.pass)).toBe(1); + expect(verdict.deltaBytes).toBe(5_000); + expect(verdict.deltaPercent).toBeGreaterThan(5); + expect(verdict.reasons.some((r) => r.includes("grew 5,000 B"))).toBe(true); + }); + + it("fails when there is no baseline, instructing a baseline update", () => { + const verdict = evaluateBundle( + { minifiedBytes: 500_000, gzipBytes: 90_000 }, + null, + CONFIG, + ); + expect(verdict.pass).toBe(false); + expect(exitCodeOf(verdict.pass)).toBe(1); + expect( + verdict.reasons.some((r) => r.includes("bundle:update-baseline")), + ).toBe(true); + }); + + it("reports minified size, gzipped size, absolute delta, and percentage change", () => { + const verdict = evaluateBundle( + { minifiedBytes: 600_000, gzipBytes: 92_000 }, + baseline, + CONFIG, + ); + expect(verdict.measured.minifiedBytes).toBe(600_000); + expect(verdict.measured.gzipBytes).toBe(92_000); + expect(verdict.deltaBytes).toBe(2_000); + expect(verdict.deltaPercent).toBeCloseTo((2_000 / 90_000) * 100, 5); + }); + + it("treats shrinkage as always fine", () => { + const verdict = evaluateBundle( + { minifiedBytes: 300_000, gzipBytes: 80_000 }, + baseline, + CONFIG, + ); + expect(verdict.pass).toBe(true); + expect(exitCodeOf(verdict.pass)).toBe(0); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts index d8d5259..79c3f5a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -846,7 +846,12 @@ describe("cloneInvoice", () => { clear: vi.fn(), }; - const result = await client.cloneInvoice("123", { newDeadline: 1_800_000_000 }); + // The fixture invoice (2023 deadline, unfunded recipient) is not cloneable; + // these tests exercise the submission path, so validation is skipped. + const result = await client.cloneInvoice("123", { + newDeadline: 1_800_000_000, + skipValidation: true, + }); expect(result).toBe("456"); expect(submitSpy).toHaveBeenCalledTimes(1); @@ -886,7 +891,9 @@ describe("cloneInvoice", () => { }; (client as any)._cache = cache; - await expect(client.cloneInvoice("123")).rejects.toThrow("network error"); + await expect( + client.cloneInvoice("123", { skipValidation: true }), + ).rejects.toThrow("network error"); expect(cache.set).not.toHaveBeenCalled(); expect(cache.invalidate).not.toHaveBeenCalled(); }); diff --git a/test/footprint.test.ts b/test/footprint.test.ts new file mode 100644 index 0000000..63950cb --- /dev/null +++ b/test/footprint.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from "vitest"; +import { + Account, + BASE_FEE, + Keypair, + Operation, + SorobanDataBuilder, + Transaction, + TransactionBuilder, + rpc as SorobanRpc, + xdr, +} from "@stellar/stellar-sdk"; +import { footprintDiff } from "../src/utils/footprintDiff.js"; +import { optimizeFootprint } from "../src/soroban/footprint.js"; +import { submitTransaction } from "../src/transaction/submit.js"; + +const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; + +const scAddressContract = + (xdr.ScAddress as unknown as { scAddressTypeContract?: (raw: Buffer) => unknown }) + .scAddressTypeContract ?? + (xdr.ScAddress as unknown as { contract: (raw: Buffer) => unknown }).contract; + +/** Build a distinct contract-data ledger key from a 32-byte contract id + symbol. */ +function contractDataKey(seed: number, symbol: string): xdr.LedgerKey { + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: scAddressContract(Buffer.alloc(32, seed)) as never, + key: xdr.ScVal.scvSymbol(symbol), + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +/** Encode a ledger key canonically for assertions. */ +function encode(key: xdr.LedgerKey): string { + return key.toXDR("base64"); +} + +/** Build a Soroban transaction declaring the given read-only / read-write keys. */ +function buildSorobanTx( + readOnly: xdr.LedgerKey[], + readWrite: xdr.LedgerKey[], +): Transaction { + const source = Keypair.random(); + const account = new Account(source.publicKey(), "0"); + const sorobanData = new SorobanDataBuilder() + .setReadOnly(readOnly) + .setReadWrite(readWrite) + .setResources(100, 100, 100) + .setResourceFee(100) + .build(); + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + sorobanData, + }) + .addOperation(Operation.extendFootprintTtl({ extendTo: 100 })) + .setTimeout(30) + .build(); +} + +/** SimulateTransactionSuccessResponse with the given minimal footprint. */ +function simSuccess( + readOnly: xdr.LedgerKey[], + readWrite: xdr.LedgerKey[], +): SorobanRpc.Api.SimulateTransactionSuccessResponse { + const transactionData = new SorobanDataBuilder() + .setReadOnly(readOnly) + .setReadWrite(readWrite) + .setResources(100, 100, 100) + .setResourceFee(100); + return { + id: "mock", + latestLedger: 100, + events: [], + transactionData, + minResourceFee: "100", + } as unknown as SorobanRpc.Api.SimulateTransactionSuccessResponse; +} + +/** Read the declared footprint off a transaction envelope. */ +function footprintOf(tx: Transaction): { + readOnly: string[]; + readWrite: string[]; +} { + const ext = tx.toEnvelope().v1().tx().ext() as unknown as { + sorobanData?: () => xdr.SorobanTransactionData; + }; + const data = ext.sorobanData?.(); + if (!data) return { readOnly: [], readWrite: [] }; + const footprint = data.resources().footprint(); + return { + readOnly: footprint.readOnly().map(encode), + readWrite: footprint.readWrite().map(encode), + }; +} + +describe("footprintDiff", () => { + const keyA = contractDataKey(1, "a"); + const keyB = contractDataKey(2, "b"); + const keyC = contractDataKey(3, "c"); + + it("classifies added, removed, and unchanged keys", () => { + const diff = footprintDiff([keyA, keyB], [keyB, keyC]); + + expect(diff.removed.map(encode)).toEqual([encode(keyA)]); + expect(diff.added.map(encode)).toEqual([encode(keyC)]); + expect(diff.unchanged.map(encode)).toEqual([encode(keyB)]); + }); + + it("treats structurally identical keys from different builders as equal", () => { + const clone = contractDataKey(2, "b"); + const diff = footprintDiff([keyA, keyB], [keyB, clone]); + expect(diff.unchanged).toHaveLength(1); + expect(diff.removed).toHaveLength(1); + expect(diff.added).toHaveLength(0); + }); + + it("handles empty sets", () => { + const diff = footprintDiff([], []); + expect(diff).toEqual({ added: [], removed: [], unchanged: [] }); + }); +}); + +describe("optimizeFootprint", () => { + const keyA = contractDataKey(1, "a"); + const keyB = contractDataKey(2, "b"); + const keyC = contractDataKey(3, "c"); + + it("trims a bloated footprint to exactly the simulation result", () => { + const tx = buildSorobanTx([keyA, keyB], [keyC]); + const sim = simSuccess([keyA], [keyC]); + + const optimized = optimizeFootprint(tx, sim); + const footprint = footprintOf(optimized); + + expect(footprint.readOnly).toEqual([encode(keyA)]); + expect(footprint.readWrite).toEqual([encode(keyC)]); + }); + + it("passes an already-minimal footprint through unchanged", () => { + const tx = buildSorobanTx([keyA], [keyC]); + const sim = simSuccess([keyA], [keyC]); + + const optimized = optimizeFootprint(tx, sim); + const footprint = footprintOf(optimized); + + expect(footprint.readOnly).toEqual([encode(keyA)]); + expect(footprint.readWrite).toEqual([encode(keyC)]); + // Envelope-level XDR should be byte-identical when nothing was pruned. + expect(optimized.toXDR()).toBe(tx.toXDR()); + }); + + it("logs each removed key at debug level", () => { + const tx = buildSorobanTx([keyA, keyB], [keyC]); + const sim = simSuccess([keyA], [keyC]); + const debug = vi.fn(); + const logger = { debug }; + + optimizeFootprint(tx, sim, { logger }); + + expect(debug).toHaveBeenCalledTimes(1); + expect(debug.mock.calls[0]![0]).toContain(encode(keyB)); + }); + + it("does not mutate the input transaction", () => { + const tx = buildSorobanTx([keyA, keyB], [keyC]); + const before = tx.toXDR(); + + optimizeFootprint(tx, simSuccess([keyA], [keyC])); + + expect(tx.toXDR()).toBe(before); + }); +}); + +describe("submitTransaction", () => { + const keyA = contractDataKey(1, "a"); + const keyB = contractDataKey(2, "b"); + + it("optimizes the footprint by default before submitting", async () => { + const tx = buildSorobanTx([keyA, keyB], []); + const sim = simSuccess([keyA], []); + const submitted = vi.fn(async (t: Transaction) => ({ + status: "PENDING", + hash: "abc", + latestLedger: 1, + latestLedgerCloseTime: 1, + })); + + await submitTransaction({ submitTransaction: submitted }, tx, sim); + + expect(submitted).toHaveBeenCalledTimes(1); + const sent = submitted.mock.calls[0]![0] as Transaction; + expect(footprintOf(sent).readOnly).toEqual([encode(keyA)]); + }); + + it("skips optimization when { optimizeFootprint: false }", async () => { + const tx = buildSorobanTx([keyA, keyB], []); + const sim = simSuccess([keyA], []); + const submitted = vi.fn(async (t: Transaction) => ({ + status: "PENDING", + hash: "abc", + latestLedger: 1, + latestLedgerCloseTime: 1, + })); + + await submitTransaction( + { submitTransaction: submitted }, + tx, + sim, + { optimizeFootprint: false }, + ); + + const sent = submitted.mock.calls[0]![0] as Transaction; + expect(footprintOf(sent).readOnly).toEqual([encode(keyA), encode(keyB)]); + }); +}); diff --git a/test/sdkExports.test.ts b/test/sdkExports.test.ts new file mode 100644 index 0000000..dcded5f --- /dev/null +++ b/test/sdkExports.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import * as sdk from "../src/index.js"; + +describe("public API surface (issues #586, #588, #589)", () => { + it("exports the signing vault adapters", () => { + expect(typeof sdk.KeypairSigner).toBe("function"); + expect(typeof sdk.EncryptedFileSigner).toBe("function"); + expect(typeof sdk.CloudKmsSigner).toBe("function"); + expect(typeof sdk.encryptSigningKeyToPem).toBe("function"); + expect(typeof sdk.writeEncryptedSigningKeyFile).toBe("function"); + }); + + it("exports the footprint optimizer utilities", () => { + expect(typeof sdk.optimizeFootprint).toBe("function"); + expect(typeof sdk.footprintDiff).toBe("function"); + expect(typeof sdk.submitTransaction).toBe("function"); + }); +}); diff --git a/test/signing.test.ts b/test/signing.test.ts new file mode 100644 index 0000000..3c27ab1 --- /dev/null +++ b/test/signing.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { Keypair } from "@stellar/stellar-sdk"; +import { KeypairSigner } from "../src/signing/adapters/KeypairSigner.js"; +import { CloudKmsSigner } from "../src/signing/adapters/CloudKmsSigner.js"; +import type { KmsClient } from "../src/signing/adapters/CloudKmsSigner.js"; +import { + EncryptedFileSigner, + writeEncryptedSigningKeyFile, + encryptSigningKeyToPem, +} from "../src/signing/adapters/EncryptedFileSigner.js"; + +const TX_HASH = randomBytes(32); + +describe("KeypairSigner", () => { + it("produces a 64-byte ed25519 signature verifiable by Keypair.verify", async () => { + const keypair = Keypair.random(); + const signer = new KeypairSigner(keypair); + + const signature = await signer.sign(TX_HASH); + + expect(signature).toHaveLength(64); + expect(keypair.verify(TX_HASH, signature)).toBe(true); + }); + + it("does not verify against a different public key", async () => { + const keypair = Keypair.random(); + const other = Keypair.random(); + const signer = new KeypairSigner(keypair); + + const signature = await signer.sign(TX_HASH); + + expect(other.verify(TX_HASH, signature)).toBe(false); + }); +}); + +describe("CloudKmsSigner", () => { + it("delegates to the injected KmsClient with the configured keyId", async () => { + const signature = randomBytes(64); + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId: string, digest: Buffer) => { + expect(digest).toEqual(TX_HASH); + return signature; + }), + }; + const signer = new CloudKmsSigner(kmsClient, "alias/split-key"); + + const result = await signer.sign(TX_HASH); + + expect(result).toEqual(signature); + expect(kmsClient.sign).toHaveBeenCalledWith("alias/split-key", TX_HASH); + }); + + it("accepts any structurally compatible object (easy test mocking)", () => { + const fakeKms = { sign: vi.fn(async () => Buffer.alloc(64, 1)) }; + const signer = new CloudKmsSigner(fakeKms, "key-id"); + expect(signer.keyId).toBe("key-id"); + expect(() => new CloudKmsSigner(fakeKms, "key-id")).not.toThrow(); + }); +}); + +describe("EncryptedFileSigner", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "split-signer-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + function filePath(name: string): string { + return join(dir, name); + } + + it("decrypts the AES-256-GCM PEM file and signs with the embedded keypair", async () => { + const keypair = Keypair.random(); + const aesKey = randomBytes(32); + const path = filePath("vault.pem"); + await writeEncryptedSigningKeyFile(path, keypair.secret(), aesKey); + + const signer = new EncryptedFileSigner(path, { aesKey }); + const signature = await signer.sign(TX_HASH); + + expect(signature).toHaveLength(64); + expect(keypair.verify(TX_HASH, signature)).toBe(true); + }); + + it("rejects a wrong AES key (auth tag mismatch)", async () => { + const keypair = Keypair.random(); + const path = filePath("vault.pem"); + await writeEncryptedSigningKeyFile(path, keypair.secret(), randomBytes(32)); + + const signer = new EncryptedFileSigner(path, { aesKey: randomBytes(32) }); + + await expect(signer.sign(TX_HASH)).rejects.toThrow(); + }); + + it("re-reads the file after clearCache (the explicit-null / collected-weakref path)", async () => { + const first = Keypair.random(); + const second = Keypair.random(); + const aesKey = randomBytes(32); + const path = filePath("vault.pem"); + await writeEncryptedSigningKeyFile(path, first.secret(), aesKey); + + const signer = new EncryptedFileSigner(path, { aesKey }); + const sig1 = await signer.sign(TX_HASH); + expect(first.verify(TX_HASH, sig1)).toBe(true); + + // clearCache() simulates the WeakRef having been collected (explicit null): + // the next sign must re-read + re-decrypt the file. + signer.clearCache(); + await writeEncryptedSigningKeyFile(path, second.secret(), aesKey); + const sig2 = await signer.sign(TX_HASH); + expect(second.verify(TX_HASH, sig2)).toBe(true); + expect(first.verify(TX_HASH, sig2)).toBe(false); + }); + + it("serves subsequent signs from cache without re-reading the file", async () => { + const keypair = Keypair.random(); + const aesKey = randomBytes(32); + const path = filePath("vault.pem"); + await writeEncryptedSigningKeyFile(path, keypair.secret(), aesKey); + + const signer = new EncryptedFileSigner(path, { aesKey }); + const sig1 = await signer.sign(TX_HASH); + + // Rewrite the file with a different secret WITHOUT clearing the cache: + // the cached keypair must still be used. + await writeEncryptedSigningKeyFile(path, Keypair.random().secret(), aesKey); + const sig2 = await signer.sign(TX_HASH); + + expect(keypair.verify(TX_HASH, sig2)).toBe(true); + expect(sig2).toEqual(sig1); + }); + + it("rejects a truncated / malformed file", async () => { + const path = filePath("vault.pem"); + await writeFile(path, "not a pem block", "utf8"); + + const signer = new EncryptedFileSigner(path, { aesKey: randomBytes(32) }); + + await expect(signer.sign(TX_HASH)).rejects.toThrow( + /does not contain a valid SPLIT ENCRYPTED SIGNING KEY block/, + ); + }); + + it("encryptSigningKeyToPem round-trips through a fresh signer", async () => { + const keypair = Keypair.random(); + const aesKey = randomBytes(32); + const pem = encryptSigningKeyToPem(keypair.secret(), aesKey); + + const path = filePath("vault.pem"); + await writeFile(path, pem, "utf8"); + + const signer = new EncryptedFileSigner(path, { aesKey }); + const signature = await signer.sign(TX_HASH); + expect(keypair.verify(TX_HASH, signature)).toBe(true); + }); + + it("validates the AES key length at construction", () => { + const path = filePath("vault.pem"); + expect(() => new EncryptedFileSigner(path, { aesKey: randomBytes(16) })).toThrow( + /aesKey must be exactly 32 bytes/, + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3bdeb5e..8998378 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020", "DOM"], + "lib": ["ES2020", "ES2021.WeakRef", "DOM"], "types": ["node"], "jsx": "react", "strict": true,