From 0222ff5aff16be98942f205185aaa66911ffdc39 Mon Sep 17 00:00:00 2001 From: ryjm Date: Mon, 24 Aug 2026 13:51:11 -0700 Subject: [PATCH 01/12] feat: add canonical withdrawal calldata codec --- package-lock.json | 517 ++++++++++++++++++ package.json | 3 + src/index.ts | 1 + src/withdrawal.ts | 391 +++++++++++++ test-fixtures/withdrawal_wire_v1_vectors.json | 89 +++ test/withdrawal.test.ts | 186 +++++++ 6 files changed, 1187 insertions(+) create mode 100644 src/withdrawal.ts create mode 100644 test-fixtures/withdrawal_wire_v1_vectors.json create mode 100644 test/withdrawal.test.ts diff --git a/package-lock.json b/package-lock.json index 86482b8..1350b6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "@noble/hashes": "2.3.0", "@nockbox/iris-wasm": "0.2.0", "@scure/base": "^2.0.0" }, "devDependencies": { "@types/node": "^22.0.0", "prettier": "^3.4.2", + "tsx": "4.23.12", "typescript": "^5.5.3", "vite": "^5.0.0" } @@ -308,6 +310,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -325,6 +344,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -342,6 +378,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -410,6 +463,18 @@ "node": ">=12" } }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nockbox/iris-wasm": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.2.0.tgz", @@ -972,6 +1037,458 @@ "node": ">=0.10.0" } }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "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.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 65e20c1..6de8198 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", + "test": "tsx --test test/withdrawal.test.ts", "prepublishOnly": "npm run build" }, "keywords": [ @@ -42,6 +43,7 @@ "devDependencies": { "@types/node": "^22.0.0", "prettier": "^3.4.2", + "tsx": "4.23.12", "typescript": "^5.5.3", "vite": "^5.0.0" }, @@ -50,6 +52,7 @@ "url": "https://github.com/nockbox/iris-sdk.git" }, "dependencies": { + "@noble/hashes": "2.3.0", "@nockbox/iris-wasm": "0.2.0", "@scure/base": "^2.0.0" } diff --git a/src/index.ts b/src/index.ts index 9bdc74a..da7a098 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,6 @@ export * from './constants.js'; export * from './validate-sign-tx-request.js'; export * from './compat.js'; export * from './bridge.js'; +export * from './withdrawal.js'; export * as wasm from './wasm.js'; export { initWasm } from './wasm.js'; diff --git a/src/withdrawal.ts b/src/withdrawal.ts new file mode 100644 index 0000000..6a18b56 --- /dev/null +++ b/src/withdrawal.ts @@ -0,0 +1,391 @@ +import { keccak_256 } from '@noble/hashes/sha3.js'; + +export const WITHDRAWAL_WIRE_V1 = { + protocol: 'WithdrawalWireV1', + selector: '0xbcf64e05', + commitmentDomain: 'nock-withdrawal-calldata-v1', + trailerMagic: 'NOCKWD1!', + baseCalldataLength: 68, + fullLockRootLength: 40, + calldataLength: 116, + tip5Prime: 0xffff_ffff_0000_0001n, +} as const; + +export const WITHDRAWAL_POLICY_V1 = { + policy: 'withdrawal-policy-v1', + baseUnitsPerNock: 10_000_000_000_000_000n, + nicksPerNock: 65_536n, + baseUnitsPerNick: 152_587_890_625n, + minimumGrossNocks: 100_000n, + minimumGrossNicks: 6_553_600_000n, + minimumGrossBaseUnits: 1_000_000_000_000_000_000_000n, + bridgeFeeNicksPerStartedNock: 195n, + maximumNicks: 0xffff_ffff_ffff_ffffn, +} as const; + +export type Hex = `0x${string}`; +export type Tip5LockRootLimbs = readonly [bigint, bigint, bigint, bigint, bigint]; + +export type WithdrawalWireErrorCode = + | 'invalid_address' + | 'invalid_amount_type' + | 'amount_not_positive' + | 'amount_not_divisible' + | 'amount_below_minimum' + | 'amount_overflow' + | 'non_positive_payout' + | 'invalid_lock_root' + | 'missing_calldata_trailer' + | 'malformed_calldata' + | 'calldata_amount_mismatch' + | 'calldata_commitment_mismatch' + | 'lock_root_mismatch'; + +export class WithdrawalWireError extends Error { + readonly code: WithdrawalWireErrorCode; + + constructor(code: WithdrawalWireErrorCode, message: string) { + super(message); + this.name = 'WithdrawalWireError'; + this.code = code; + Object.setPrototypeOf(this, WithdrawalWireError.prototype); + } +} + +export interface WithdrawalWireV1Input { + nockTokenAddress: string; + burnerAddress: string; + amountBaseUnits: bigint; + lockRootLimbs: Tip5LockRootLimbs; +} + +export interface WithdrawalPolicyV1Amount { + amountBaseUnits: bigint; + amountNicks: bigint; + bridgeFeeNicks: bigint; + amountAfterBridgeFeeNicks: bigint; +} + +export interface DecodedWithdrawalWireV1 { + protocol: 'WithdrawalWireV1'; + selector: Hex; + amountBaseUnits: bigint; + commitment: Hex; + trailerMagic: 'NOCKWD1!'; + lockRootLimbs: Tip5LockRootLimbs; + calldata: Hex; +} + +export interface EncodedWithdrawalWireV1 extends DecodedWithdrawalWireV1 { + nockTokenAddress: Hex; + burnerAddress: Hex; + amountNicks: bigint; + bridgeFeeNicks: bigint; +} + +interface NormalizedWithdrawalWireV1Input { + nockTokenAddress: Hex; + nockTokenAddressBytes: Uint8Array; + burnerAddress: Hex; + burnerAddressBytes: Uint8Array; + amountBaseUnits: bigint; + amountBytes: Uint8Array; + amountPolicy: WithdrawalPolicyV1Amount; + lockRootLimbs: Tip5LockRootLimbs; + lockRootBytes: Uint8Array; +} + +const SELECTOR_BYTES = hexToBytes(WITHDRAWAL_WIRE_V1.selector, 'selector'); +const TRAILER_MAGIC_BYTES = new TextEncoder().encode(WITHDRAWAL_WIRE_V1.trailerMagic); +const COMMITMENT_DOMAIN_BYTES = new TextEncoder().encode(WITHDRAWAL_WIRE_V1.commitmentDomain); + +export function validateWithdrawalPolicyV1Amount( + amountBaseUnits: bigint +): WithdrawalPolicyV1Amount { + if (typeof amountBaseUnits !== 'bigint') { + throw new WithdrawalWireError( + 'invalid_amount_type', + 'Withdrawal amount must be a bigint in wrapped-NOCK base units' + ); + } + if (amountBaseUnits <= 0n) { + throw new WithdrawalWireError('amount_not_positive', 'Withdrawal amount must be positive'); + } + if (amountBaseUnits % WITHDRAWAL_POLICY_V1.baseUnitsPerNick !== 0n) { + throw new WithdrawalWireError( + 'amount_not_divisible', + `Withdrawal amount must be divisible by ${WITHDRAWAL_POLICY_V1.baseUnitsPerNick} base units per nick` + ); + } + + const amountNicks = amountBaseUnits / WITHDRAWAL_POLICY_V1.baseUnitsPerNick; + if (amountNicks > WITHDRAWAL_POLICY_V1.maximumNicks) { + throw new WithdrawalWireError( + 'amount_overflow', + 'Withdrawal amount exceeds the backend u64 nick representation' + ); + } + if (amountNicks < WITHDRAWAL_POLICY_V1.minimumGrossNicks) { + throw new WithdrawalWireError( + 'amount_below_minimum', + `Withdrawal amount is below the inclusive ${WITHDRAWAL_POLICY_V1.minimumGrossNocks}-NOCK minimum` + ); + } + + const startedNocks = + (amountNicks + WITHDRAWAL_POLICY_V1.nicksPerNock - 1n) / WITHDRAWAL_POLICY_V1.nicksPerNock; + const bridgeFeeNicks = startedNocks * WITHDRAWAL_POLICY_V1.bridgeFeeNicksPerStartedNock; + const amountAfterBridgeFeeNicks = amountNicks - bridgeFeeNicks; + if (amountAfterBridgeFeeNicks <= 0n) { + throw new WithdrawalWireError( + 'non_positive_payout', + 'Withdrawal bridge fee leaves no positive amount for payout and transaction fees' + ); + } + + return { + amountBaseUnits, + amountNicks, + bridgeFeeNicks, + amountAfterBridgeFeeNicks, + }; +} + +export function withdrawalCommitmentV1(input: WithdrawalWireV1Input): Hex { + const normalized = normalizeInput(input); + return bytesToHex( + keccak_256( + concatBytes( + COMMITMENT_DOMAIN_BYTES, + normalized.nockTokenAddressBytes, + normalized.burnerAddressBytes, + normalized.amountBytes, + normalized.lockRootBytes + ) + ) + ); +} + +export function encodeWithdrawalWireV1(input: WithdrawalWireV1Input): EncodedWithdrawalWireV1 { + const normalized = normalizeInput(input); + const commitmentBytes = keccak_256( + concatBytes( + COMMITMENT_DOMAIN_BYTES, + normalized.nockTokenAddressBytes, + normalized.burnerAddressBytes, + normalized.amountBytes, + normalized.lockRootBytes + ) + ); + const calldataBytes = concatBytes( + SELECTOR_BYTES, + normalized.amountBytes, + commitmentBytes, + TRAILER_MAGIC_BYTES, + normalized.lockRootBytes + ); + + const decoded = validateWithdrawalWireV1(calldataBytes, input); + return { + ...decoded, + nockTokenAddress: normalized.nockTokenAddress, + burnerAddress: normalized.burnerAddress, + amountNicks: normalized.amountPolicy.amountNicks, + bridgeFeeNicks: normalized.amountPolicy.bridgeFeeNicks, + }; +} + +export function decodeWithdrawalWireV1(calldata: Hex | Uint8Array): DecodedWithdrawalWireV1 { + const bytes = typeof calldata === 'string' ? hexToBytes(calldata, 'calldata') : calldata.slice(); + if (bytes.length === WITHDRAWAL_WIRE_V1.baseCalldataLength) { + throw new WithdrawalWireError( + 'missing_calldata_trailer', + `Withdrawal calldata is missing the ${WITHDRAWAL_WIRE_V1.trailerMagic} trailer` + ); + } + if (bytes.length !== WITHDRAWAL_WIRE_V1.calldataLength) { + throw new WithdrawalWireError( + 'malformed_calldata', + `Withdrawal calldata must be exactly ${WITHDRAWAL_WIRE_V1.calldataLength} bytes, got ${bytes.length}` + ); + } + if (!equalBytes(bytes.subarray(0, 4), SELECTOR_BYTES)) { + throw new WithdrawalWireError( + 'malformed_calldata', + 'Withdrawal calldata selector is not burn(uint256,bytes32)' + ); + } + if (!equalBytes(bytes.subarray(68, 76), TRAILER_MAGIC_BYTES)) { + throw new WithdrawalWireError( + 'malformed_calldata', + `Withdrawal calldata is missing the ${WITHDRAWAL_WIRE_V1.trailerMagic} magic` + ); + } + + const lockRootLimbs: Tip5LockRootLimbs = [ + bytesToBigint(bytes.subarray(76, 84)), + bytesToBigint(bytes.subarray(84, 92)), + bytesToBigint(bytes.subarray(92, 100)), + bytesToBigint(bytes.subarray(100, 108)), + bytesToBigint(bytes.subarray(108, 116)), + ]; + validateLockRootLimbs(lockRootLimbs); + + return { + protocol: 'WithdrawalWireV1', + selector: bytesToHex(bytes.subarray(0, 4)), + amountBaseUnits: bytesToBigint(bytes.subarray(4, 36)), + commitment: bytesToHex(bytes.subarray(36, 68)), + trailerMagic: 'NOCKWD1!', + lockRootLimbs, + calldata: bytesToHex(bytes), + }; +} + +export function validateWithdrawalWireV1( + calldata: Hex | Uint8Array, + expected: WithdrawalWireV1Input +): DecodedWithdrawalWireV1 { + const decoded = decodeWithdrawalWireV1(calldata); + const normalized = normalizeInput(expected); + + if (decoded.amountBaseUnits !== normalized.amountBaseUnits) { + throw new WithdrawalWireError( + 'calldata_amount_mismatch', + 'Withdrawal calldata amount does not match the current request' + ); + } + if (!equalBigintTuples(decoded.lockRootLimbs, normalized.lockRootLimbs)) { + throw new WithdrawalWireError( + 'lock_root_mismatch', + 'Withdrawal calldata destination does not match the current request' + ); + } + + const expectedCommitment = bytesToHex( + keccak_256( + concatBytes( + COMMITMENT_DOMAIN_BYTES, + normalized.nockTokenAddressBytes, + normalized.burnerAddressBytes, + normalized.amountBytes, + normalized.lockRootBytes + ) + ) + ); + if (decoded.commitment !== expectedCommitment) { + throw new WithdrawalWireError( + 'calldata_commitment_mismatch', + 'Withdrawal calldata commitment does not match token, burner, amount, and destination' + ); + } + + return decoded; +} + +function normalizeInput(input: WithdrawalWireV1Input): NormalizedWithdrawalWireV1Input { + const nockTokenAddress = normalizeAddress(input.nockTokenAddress, 'Nock token address'); + const burnerAddress = normalizeAddress(input.burnerAddress, 'burner address'); + const amountPolicy = validateWithdrawalPolicyV1Amount(input.amountBaseUnits); + validateLockRootLimbs(input.lockRootLimbs); + + return { + nockTokenAddress, + nockTokenAddressBytes: hexToBytes(nockTokenAddress, 'Nock token address'), + burnerAddress, + burnerAddressBytes: hexToBytes(burnerAddress, 'burner address'), + amountBaseUnits: input.amountBaseUnits, + amountBytes: bigintToFixedBytes(input.amountBaseUnits, 32, 'withdrawal amount'), + amountPolicy, + lockRootLimbs: input.lockRootLimbs, + lockRootBytes: concatBytes( + ...input.lockRootLimbs.map(limb => bigintToFixedBytes(limb, 8, 'Tip5 limb')) + ), + }; +} + +function normalizeAddress(value: string, label: string): Hex { + const normalized = value.trim().toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(normalized)) { + throw new WithdrawalWireError('invalid_address', `${label} must be a 20-byte 0x address`); + } + return normalized as Hex; +} + +function validateLockRootLimbs(limbs: readonly bigint[]): asserts limbs is Tip5LockRootLimbs { + if (!Array.isArray(limbs) || limbs.length !== 5) { + throw new WithdrawalWireError( + 'invalid_lock_root', + 'Withdrawal destination must contain exactly five Tip5 limbs' + ); + } + for (const [index, limb] of limbs.entries()) { + if (typeof limb !== 'bigint' || limb < 0n || limb >= WITHDRAWAL_WIRE_V1.tip5Prime) { + throw new WithdrawalWireError( + 'invalid_lock_root', + `Withdrawal destination limb ${index} is outside the Tip5 base field` + ); + } + } +} + +function hexToBytes(value: string, label: string): Uint8Array { + if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(value)) { + throw new WithdrawalWireError('malformed_calldata', `${label} must be even-length 0x hex`); + } + const body = value.slice(2); + const bytes = new Uint8Array(body.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(body.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): Hex { + let body = ''; + for (const byte of bytes) body += byte.toString(16).padStart(2, '0'); + return `0x${body}`; +} + +function bigintToFixedBytes(value: bigint, length: number, label: string): Uint8Array { + if (typeof value !== 'bigint' || value < 0n || value > (1n << BigInt(length * 8)) - 1n) { + throw new WithdrawalWireError('malformed_calldata', `${label} does not fit ${length} bytes`); + } + const bytes = new Uint8Array(length); + let remaining = value; + for (let index = length - 1; index >= 0; index -= 1) { + bytes[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return bytes; +} + +function bytesToBigint(bytes: Uint8Array): bigint { + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + return value; +} + +function concatBytes(...parts: readonly Uint8Array[]): Uint8Array { + const length = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; +} + +function equalBigintTuples(left: readonly bigint[], right: readonly bigint[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/test-fixtures/withdrawal_wire_v1_vectors.json b/test-fixtures/withdrawal_wire_v1_vectors.json new file mode 100644 index 0000000..a4d3503 --- /dev/null +++ b/test-fixtures/withdrawal_wire_v1_vectors.json @@ -0,0 +1,89 @@ +{ + "schema_version": 1, + "protocol": "WithdrawalWireV1", + "constants": { + "commitment_domain": "nock-withdrawal-calldata-v1", + "calldata_length": 116, + "base_calldata_length": 68, + "trailer_magic_ascii": "NOCKWD1!", + "full_lock_root_length": 40, + "tip5_prime": "18446744069414584321", + "nock_base_units_per_nick": "152587890625" + }, + "valid_vectors": [ + { + "name": "minimum_inclusive", + "nock_token_address": "0x1111111111111111111111111111111111111111", + "burner_address": "0x2222222222222222222222222222222222222222", + "amount_base_units": "1000000000000000000000", + "lock_root_limbs": ["1", "2", "3", "4", "5"], + "selector": "0xbcf64e05", + "commitment": "0x76bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab03231", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b5744312100000000000000010000000000000002000000000000000300000000000000040000000000000005" + }, + { + "name": "leading_zero_and_max_limb", + "nock_token_address": "0xa5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5", + "burner_address": "0x5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + "amount_base_units": "1000000000152587890625", + "lock_root_limbs": ["0", "18446744069414584320", "42", "4294967296", "99"], + "selector": "0xbcf64e05", + "commitment": "0xf463c089b4074b6f63e50a0552d584ff24743aa003042a94062a9a56d5727e90", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9ade965926fc1f463c089b4074b6f63e50a0552d584ff24743aa003042a94062a9a56d5727e904e4f434b574431210000000000000000ffffffff00000000000000000000002a00000001000000000000000000000063" + }, + { + "name": "maximum_u64_nicks", + "nock_token_address": "0x0101010101010101010101010101010101010101", + "burner_address": "0xfefefefefefefefefefefefefefefefefefefefe", + "amount_base_units": "2814749767106559999847412109375", + "lock_root_limbs": ["18446744069414584319", "0", "1", "2", "3"], + "selector": "0xbcf64e05", + "commitment": "0x991a7b8a2bfb015302153ce9bb5576b9f4968200de9cbff39262abd3f1ed19f0", + "calldata": "0xbcf64e05000000000000000000000000000000000000002386f26fc0ffffffdc790d903f991a7b8a2bfb015302153ce9bb5576b9f4968200de9cbff39262abd3f1ed19f04e4f434b57443121fffffffeffffffff0000000000000000000000000000000100000000000000020000000000000003" + } + ], + "invalid_vectors": [ + { + "name": "missing_trailer", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab03231", + "expected_error": "missing_calldata_trailer" + }, + { + "name": "wrong_selector", + "base_vector": "minimum_inclusive", + "calldata": "0xbdf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b5744312100000000000000010000000000000002000000000000000300000000000000040000000000000005", + "expected_error": "malformed_calldata" + }, + { + "name": "wrong_magic", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab03231004f434b5744312100000000000000010000000000000002000000000000000300000000000000040000000000000005", + "expected_error": "malformed_calldata" + }, + { + "name": "limb_equal_to_prime", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b57443121ffffffff000000010000000000000002000000000000000300000000000000040000000000000005", + "expected_error": "invalid_lock_root" + }, + { + "name": "extra_trailing_byte", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000076bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b574431210000000000000001000000000000000200000000000000030000000000000004000000000000000500", + "expected_error": "malformed_calldata" + }, + { + "name": "calldata_amount_mismatch", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000176bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b5744312100000000000000010000000000000002000000000000000300000000000000040000000000000005", + "expected_error": "calldata_amount_mismatch" + }, + { + "name": "calldata_commitment_mismatch", + "base_vector": "minimum_inclusive", + "calldata": "0xbcf64e0500000000000000000000000000000000000000000000003635c9adc5dea0000077bdf80083f6c3ea86e4ddd4fd62c28d0400c1c783a1e5919afdd5e9cab032314e4f434b5744312100000000000000010000000000000002000000000000000300000000000000040000000000000005", + "expected_error": "calldata_commitment_mismatch" + } + ] +} diff --git a/test/withdrawal.test.ts b/test/withdrawal.test.ts new file mode 100644 index 0000000..085009b --- /dev/null +++ b/test/withdrawal.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + WITHDRAWAL_POLICY_V1, + WITHDRAWAL_WIRE_V1, + WithdrawalWireError, + decodeWithdrawalWireV1, + encodeWithdrawalWireV1, + validateWithdrawalPolicyV1Amount, + validateWithdrawalWireV1, + withdrawalCommitmentV1, + type Tip5LockRootLimbs, + type WithdrawalWireErrorCode, + type WithdrawalWireV1Input, +} from '../src/withdrawal.js'; + +interface ValidFixture { + name: string; + nock_token_address: string; + burner_address: string; + amount_base_units: string; + lock_root_limbs: [string, string, string, string, string]; + selector: string; + commitment: string; + calldata: string; +} + +interface InvalidFixture { + name: string; + base_vector: string; + calldata: string; + expected_error: WithdrawalWireErrorCode; +} + +interface WireFixture { + schema_version: number; + protocol: string; + constants: { + commitment_domain: string; + calldata_length: number; + base_calldata_length: number; + trailer_magic_ascii: string; + full_lock_root_length: number; + tip5_prime: string; + nock_base_units_per_nick: string; + }; + valid_vectors: ValidFixture[]; + invalid_vectors: InvalidFixture[]; +} + +function loadFixture(): WireFixture { + const path = new URL('../test-fixtures/withdrawal_wire_v1_vectors.json', import.meta.url); + const raw = readFileSync(path, 'utf8'); + try { + return JSON.parse(raw) as WireFixture; + } catch (error) { + throw new Error( + `Failed to parse WithdrawalWireV1 fixture at ${path.pathname}: ${String(error)}` + ); + } +} + +const fixture = loadFixture(); + +function inputFromFixture(vector: ValidFixture): WithdrawalWireV1Input { + return { + nockTokenAddress: vector.nock_token_address, + burnerAddress: vector.burner_address, + amountBaseUnits: BigInt(vector.amount_base_units), + lockRootLimbs: [ + BigInt(vector.lock_root_limbs[0]), + BigInt(vector.lock_root_limbs[1]), + BigInt(vector.lock_root_limbs[2]), + BigInt(vector.lock_root_limbs[3]), + BigInt(vector.lock_root_limbs[4]), + ], + }; +} + +function expectWireError(action: () => unknown, code: WithdrawalWireErrorCode): void { + assert.throws(action, (error: unknown) => { + assert.ok(error instanceof WithdrawalWireError); + assert.equal(error.code, code); + return true; + }); +} + +test('fixture constants match WithdrawalWireV1', () => { + assert.equal(fixture.schema_version, 1); + assert.equal(fixture.protocol, WITHDRAWAL_WIRE_V1.protocol); + assert.equal(fixture.constants.commitment_domain, WITHDRAWAL_WIRE_V1.commitmentDomain); + assert.equal(fixture.constants.calldata_length, WITHDRAWAL_WIRE_V1.calldataLength); + assert.equal(fixture.constants.base_calldata_length, WITHDRAWAL_WIRE_V1.baseCalldataLength); + assert.equal(fixture.constants.trailer_magic_ascii, WITHDRAWAL_WIRE_V1.trailerMagic); + assert.equal(fixture.constants.full_lock_root_length, WITHDRAWAL_WIRE_V1.fullLockRootLength); + assert.equal(BigInt(fixture.constants.tip5_prime), WITHDRAWAL_WIRE_V1.tip5Prime); + assert.equal( + BigInt(fixture.constants.nock_base_units_per_nick), + WITHDRAWAL_POLICY_V1.baseUnitsPerNick + ); +}); + +test('valid Rust vectors match the TypeScript codec byte-for-byte', () => { + for (const vector of fixture.valid_vectors) { + const input = inputFromFixture(vector); + const encoded = encodeWithdrawalWireV1(input); + + assert.equal(encoded.selector, vector.selector, vector.name); + assert.equal(encoded.commitment, vector.commitment, vector.name); + assert.equal(encoded.calldata, vector.calldata, vector.name); + assert.equal(withdrawalCommitmentV1(input), vector.commitment, vector.name); + assert.deepEqual(encoded.lockRootLimbs, vector.lock_root_limbs.map(BigInt), vector.name); + assert.deepEqual(validateWithdrawalWireV1(encoded.calldata, input), { + protocol: WITHDRAWAL_WIRE_V1.protocol, + selector: vector.selector, + amountBaseUnits: BigInt(vector.amount_base_units), + commitment: vector.commitment, + trailerMagic: WITHDRAWAL_WIRE_V1.trailerMagic, + lockRootLimbs: vector.lock_root_limbs.map(BigInt), + calldata: vector.calldata, + }); + } +}); + +test('malformed Rust vectors match the rejection taxonomy', () => { + const vectorsByName = new Map(fixture.valid_vectors.map(vector => [vector.name, vector])); + + for (const invalid of fixture.invalid_vectors) { + const base = vectorsByName.get(invalid.base_vector); + assert.ok(base, `${invalid.name} references a known base vector`); + const input = inputFromFixture(base); + const action = invalid.expected_error.startsWith('calldata_') + ? () => validateWithdrawalWireV1(invalid.calldata as `0x${string}`, input) + : () => decodeWithdrawalWireV1(invalid.calldata as `0x${string}`); + expectWireError(action, invalid.expected_error); + } +}); + +test('official encoder rejects number amounts and stale account bindings', () => { + const base = fixture.valid_vectors[0]; + const input = inputFromFixture(base); + + expectWireError( + () => + encodeWithdrawalWireV1({ + ...input, + amountBaseUnits: 100_000 as unknown as bigint, + }), + 'invalid_amount_type' + ); + expectWireError( + () => + validateWithdrawalWireV1(base.calldata as `0x${string}`, { + ...input, + burnerAddress: '0x3333333333333333333333333333333333333333', + }), + 'calldata_commitment_mismatch' + ); +}); + +test('policy v1 enforces inclusive minimum, divisibility, and u64 nicks', () => { + const minimum = validateWithdrawalPolicyV1Amount(WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits); + assert.equal(minimum.amountNicks, WITHDRAWAL_POLICY_V1.minimumGrossNicks); + assert.ok(minimum.amountAfterBridgeFeeNicks > 0n); + + expectWireError( + () => + validateWithdrawalPolicyV1Amount( + WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits - WITHDRAWAL_POLICY_V1.baseUnitsPerNick + ), + 'amount_below_minimum' + ); + expectWireError( + () => validateWithdrawalPolicyV1Amount(WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits + 1n), + 'amount_not_divisible' + ); + expectWireError( + () => + validateWithdrawalPolicyV1Amount( + (WITHDRAWAL_POLICY_V1.maximumNicks + 1n) * WITHDRAWAL_POLICY_V1.baseUnitsPerNick + ), + 'amount_overflow' + ); +}); From 111b88eabd2ea71d8a65d3e3caa298b4e2c26d76 Mon Sep 17 00:00:00 2001 From: ryjm Date: Mon, 24 Aug 2026 13:53:23 -0700 Subject: [PATCH 02/12] chore: prepare Iris SDK 0.3.0 release --- .github/workflows/ci.yml | 3 +++ README.md | 46 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 3 ++- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e2c4b..f20a85a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: TypeScript compilation check run: npm run typecheck + - name: Withdrawal protocol vectors + run: npm test + - name: Format check run: npm run format:check diff --git a/README.md b/README.md index 4e4e1d8..f8a85d4 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,49 @@ The current provider API is version 1. Legacy API 0 transaction signing remains supported through the compatibility mapper. Optional transaction notes remain in the request type so existing dApps and API 0 wallets can interoperate, but their security status is explicitly advisory. + +## Base to Nockchain withdrawals + +Version 0.3 adds the canonical retained-contract `WithdrawalWireV1` codec. +Official clients must use this codec instead of calling +`burn(uint256,bytes32)` through a generated ABI helper. + +```ts +import { encodeWithdrawalWireV1, validateWithdrawalWireV1 } from '@nockbox/iris-sdk'; + +const encoded = encodeWithdrawalWireV1({ + nockTokenAddress: '0x1111111111111111111111111111111111111111', + burnerAddress: connectedAccount, + amountBaseUnits: 1_000_000_000_000_000_000_000n, + lockRootLimbs: [1n, 2n, 3n, 4n, 5n], +}); + +// Use encoded.calldata unchanged for simulation, gas estimation, and sending. +validateWithdrawalWireV1(encoded.calldata, { + nockTokenAddress: encoded.nockTokenAddress, + burnerAddress: encoded.burnerAddress, + amountBaseUnits: encoded.amountBaseUnits, + lockRootLimbs: encoded.lockRootLimbs, +}); +``` + +The codec: + +- returns exactly 116 calldata bytes +- binds token, connected burner, exact bigint amount, and five Tip5 limbs +- locally decodes and validates the final bytes before returning +- enforces `withdrawal-policy-v1` amount divisibility and inclusive + 100,000-NOCK minimum +- throws typed `WithdrawalWireError` values for malformed or stale requests + +Changing account, chain, token, amount, or destination invalidates previously +constructed calldata. The application must rebuild before simulation or +submission. + +An ordinary 68-byte generated-ABI burn is unsupported even though the retained +contract accepts it. It omits the full destination and can burn funds without a +processable withdrawal. + +The published package includes +`test-fixtures/withdrawal_wire_v1_vectors.json`, sourced from the canonical Rust +bridge fixture. SDK consumers can use it to verify byte-for-byte compatibility. diff --git a/package-lock.json b/package-lock.json index 1350b6d..ec0ed50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@noble/hashes": "2.3.0", diff --git a/package.json b/package.json index 6de8198..95ee6d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.2.0", + "version": "0.3.0", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", @@ -17,6 +17,7 @@ }, "files": [ "dist", + "test-fixtures", "README.md" ], "scripts": { From d4b8f807d6ff3ef37868b2b056dd5f7a354506ad Mon Sep 17 00:00:00 2001 From: ryjm Date: Mon, 24 Aug 2026 14:43:03 -0700 Subject: [PATCH 03/12] test: add withdrawal policy boundary matrix --- test-fixtures/withdrawal_wire_v1_vectors.json | 46 ++++++++++++++++ test/withdrawal.test.ts | 53 +++++++++++-------- 2 files changed, 76 insertions(+), 23 deletions(-) diff --git a/test-fixtures/withdrawal_wire_v1_vectors.json b/test-fixtures/withdrawal_wire_v1_vectors.json index a4d3503..7509596 100644 --- a/test-fixtures/withdrawal_wire_v1_vectors.json +++ b/test-fixtures/withdrawal_wire_v1_vectors.json @@ -42,6 +42,52 @@ "calldata": "0xbcf64e05000000000000000000000000000000000000002386f26fc0ffffffdc790d903f991a7b8a2bfb015302153ce9bb5576b9f4968200de9cbff39262abd3f1ed19f04e4f434b57443121fffffffeffffffff0000000000000000000000000000000100000000000000020000000000000003" } ], + "amount_policy_vectors": [ + { + "name": "zero", + "amount_base_units": "0", + "expected": "amount_not_positive" + }, + { + "name": "one_nick_below_minimum", + "amount_base_units": "999999999847412109375", + "expected": "amount_below_minimum" + }, + { + "name": "minimum_inclusive", + "amount_base_units": "1000000000000000000000", + "expected": "valid", + "amount_nicks": "6553600000", + "bridge_fee_nicks": "19500000", + "amount_after_bridge_fee_nicks": "6534100000" + }, + { + "name": "minimum_plus_one_nick", + "amount_base_units": "1000000000152587890625", + "expected": "valid", + "amount_nicks": "6553600001", + "bridge_fee_nicks": "19500195", + "amount_after_bridge_fee_nicks": "6534099806" + }, + { + "name": "one_base_unit_dust", + "amount_base_units": "1000000000000000000001", + "expected": "amount_not_divisible" + }, + { + "name": "maximum_u64_nicks", + "amount_base_units": "2814749767106559999847412109375", + "expected": "valid", + "amount_nicks": "18446744073709551615", + "bridge_fee_nicks": "54887620458577920", + "amount_after_bridge_fee_nicks": "18391856453250973695" + }, + { + "name": "overflow_u64_nicks", + "amount_base_units": "2814749767106560000000000000000", + "expected": "amount_overflow" + } + ], "invalid_vectors": [ { "name": "missing_trailer", diff --git a/test/withdrawal.test.ts b/test/withdrawal.test.ts index 085009b..927d0bb 100644 --- a/test/withdrawal.test.ts +++ b/test/withdrawal.test.ts @@ -27,6 +27,15 @@ interface ValidFixture { calldata: string; } +interface AmountPolicyFixture { + name: string; + amount_base_units: string; + expected: 'valid' | WithdrawalWireErrorCode; + amount_nicks?: string; + bridge_fee_nicks?: string; + amount_after_bridge_fee_nicks?: string; +} + interface InvalidFixture { name: string; base_vector: string; @@ -47,6 +56,7 @@ interface WireFixture { nock_base_units_per_nick: string; }; valid_vectors: ValidFixture[]; + amount_policy_vectors: AmountPolicyFixture[]; invalid_vectors: InvalidFixture[]; } @@ -160,27 +170,24 @@ test('official encoder rejects number amounts and stale account bindings', () => ); }); -test('policy v1 enforces inclusive minimum, divisibility, and u64 nicks', () => { - const minimum = validateWithdrawalPolicyV1Amount(WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits); - assert.equal(minimum.amountNicks, WITHDRAWAL_POLICY_V1.minimumGrossNicks); - assert.ok(minimum.amountAfterBridgeFeeNicks > 0n); - - expectWireError( - () => - validateWithdrawalPolicyV1Amount( - WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits - WITHDRAWAL_POLICY_V1.baseUnitsPerNick - ), - 'amount_below_minimum' - ); - expectWireError( - () => validateWithdrawalPolicyV1Amount(WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits + 1n), - 'amount_not_divisible' - ); - expectWireError( - () => - validateWithdrawalPolicyV1Amount( - (WITHDRAWAL_POLICY_V1.maximumNicks + 1n) * WITHDRAWAL_POLICY_V1.baseUnitsPerNick - ), - 'amount_overflow' - ); +test('policy v1 matches the shared amount boundary matrix', () => { + for (const vector of fixture.amount_policy_vectors) { + const amountBaseUnits = BigInt(vector.amount_base_units); + if (vector.expected !== 'valid') { + expectWireError(() => validateWithdrawalPolicyV1Amount(amountBaseUnits), vector.expected); + continue; + } + + assert.ok(vector.amount_nicks, `${vector.name} has amount nicks`); + assert.ok(vector.bridge_fee_nicks, `${vector.name} has bridge fee`); + assert.ok(vector.amount_after_bridge_fee_nicks, `${vector.name} has amount after bridge fee`); + const result = validateWithdrawalPolicyV1Amount(amountBaseUnits); + assert.equal(result.amountNicks, BigInt(vector.amount_nicks), vector.name); + assert.equal(result.bridgeFeeNicks, BigInt(vector.bridge_fee_nicks), vector.name); + assert.equal( + result.amountAfterBridgeFeeNicks, + BigInt(vector.amount_after_bridge_fee_nicks), + vector.name + ); + } }); From 8271dc3c93f723c6743d943444f972d4dfd7faea Mon Sep 17 00:00:00 2001 From: ryjm Date: Tue, 25 Aug 2026 02:47:57 -0700 Subject: [PATCH 04/12] feat: resolve canonical withdrawal destinations --- src/withdrawal.ts | 77 +++++++++++++++++++++++++++++++++++++++++ test/withdrawal.test.ts | 39 +++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/withdrawal.ts b/src/withdrawal.ts index 6a18b56..751bd51 100644 --- a/src/withdrawal.ts +++ b/src/withdrawal.ts @@ -1,4 +1,7 @@ import { keccak_256 } from '@noble/hashes/sha3.js'; +import { base58 } from '@scure/base'; +import { initWasm, pkhSingle, spendConditionHash, spendConditionNewPkh } from './wasm.js'; +import type { Digest } from './wasm.js'; export const WITHDRAWAL_WIRE_V1 = { protocol: 'WithdrawalWireV1', @@ -26,6 +29,17 @@ export const WITHDRAWAL_POLICY_V1 = { export type Hex = `0x${string}`; export type Tip5LockRootLimbs = readonly [bigint, bigint, bigint, bigint, bigint]; +export type WithdrawalDestinationV1 = + | { kind: 'v1_pkh'; value: string } + | { kind: 'lock_root'; value: string }; + +export interface ResolvedWithdrawalDestinationV1 { + kind: WithdrawalDestinationV1['kind']; + normalizedDestination: string; + lockRoot: string; + lockRootLimbs: Tip5LockRootLimbs; +} + export type WithdrawalWireErrorCode = | 'invalid_address' | 'invalid_amount_type' @@ -283,6 +297,69 @@ export function validateWithdrawalWireV1( return decoded; } +export function tip5LockRootLimbsFromBase58(lockRoot: string): Tip5LockRootLimbs { + const normalized = normalizeTip5Digest(lockRoot, 'Nockchain lock root'); + const bytes = base58.decode(normalized); + const limbs: Tip5LockRootLimbs = [ + bytesToBigint(bytes.subarray(0, 8)), + bytesToBigint(bytes.subarray(8, 16)), + bytesToBigint(bytes.subarray(16, 24)), + bytesToBigint(bytes.subarray(24, 32)), + bytesToBigint(bytes.subarray(32, 40)), + ]; + validateLockRootLimbs(limbs); + return limbs; +} + +export async function resolveWithdrawalDestinationV1( + destination: WithdrawalDestinationV1 +): Promise { + const normalizedDestination = normalizeTip5Digest( + destination.value, + destination.kind === 'v1_pkh' ? 'Nockchain v1 PKH address' : 'Nockchain lock root' + ); + + let lockRoot = normalizedDestination; + if (destination.kind === 'v1_pkh') { + await initWasm(); + const spendCondition = spendConditionNewPkh(pkhSingle(normalizedDestination as Digest)); + lockRoot = normalizeTip5Digest( + String(spendConditionHash(spendCondition)), + 'derived Nockchain lock root' + ); + } + + return { + kind: destination.kind, + normalizedDestination, + lockRoot, + lockRootLimbs: tip5LockRootLimbsFromBase58(lockRoot), + }; +} + +function normalizeTip5Digest(value: string, label: string): string { + const normalized = value.trim(); + let bytes: Uint8Array; + try { + bytes = base58.decode(normalized); + } catch { + throw new WithdrawalWireError('invalid_lock_root', `${label} must be canonical base58`); + } + if (bytes.length !== WITHDRAWAL_WIRE_V1.fullLockRootLength) { + throw new WithdrawalWireError( + 'invalid_lock_root', + `${label} must decode to exactly ${WITHDRAWAL_WIRE_V1.fullLockRootLength} bytes` + ); + } + if (base58.encode(bytes) !== normalized) { + throw new WithdrawalWireError( + 'invalid_lock_root', + `${label} must use canonical base58 encoding` + ); + } + return normalized; +} + function normalizeInput(input: WithdrawalWireV1Input): NormalizedWithdrawalWireV1Input { const nockTokenAddress = normalizeAddress(input.nockTokenAddress, 'Nock token address'); const burnerAddress = normalizeAddress(input.burnerAddress, 'burner address'); diff --git a/test/withdrawal.test.ts b/test/withdrawal.test.ts index 927d0bb..4a98193 100644 --- a/test/withdrawal.test.ts +++ b/test/withdrawal.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { base58 } from '@scure/base'; import { WITHDRAWAL_POLICY_V1, @@ -11,10 +12,13 @@ import { validateWithdrawalPolicyV1Amount, validateWithdrawalWireV1, withdrawalCommitmentV1, + resolveWithdrawalDestinationV1, + tip5LockRootLimbsFromBase58, type Tip5LockRootLimbs, type WithdrawalWireErrorCode, type WithdrawalWireV1Input, } from '../src/withdrawal.js'; +import { initWasm } from '../src/wasm.js'; interface ValidFixture { name: string; @@ -191,3 +195,38 @@ test('policy v1 matches the shared amount boundary matrix', () => { ); } }); + +test('canonical direct lock root decodes to five big-endian Tip5 limbs', () => { + const bytes = new Uint8Array(40); + for (let index = 0; index < 5; index += 1) { + bytes[index * 8 + 7] = index + 1; + } + const lockRoot = base58.encode(bytes); + + assert.deepEqual(tip5LockRootLimbsFromBase58(lockRoot), [1n, 2n, 3n, 4n, 5n]); +}); + +test('v1 PKH destination resolves through its canonical spend lock root', async () => { + const destination = 'AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh'; + const wasmBytes = readFileSync( + new URL('../node_modules/@nockbox/iris-wasm/iris_wasm_bg.wasm', import.meta.url) + ); + await initWasm({ module_or_path: wasmBytes }); + const resolved = await resolveWithdrawalDestinationV1({ + kind: 'v1_pkh', + value: destination, + }); + + assert.equal(resolved.kind, 'v1_pkh'); + assert.equal(resolved.normalizedDestination, destination); + assert.equal(base58.decode(resolved.lockRoot).length, 40); + assert.equal(resolved.lockRootLimbs.length, 5); + assert.deepEqual(resolved.lockRootLimbs, tip5LockRootLimbsFromBase58(resolved.lockRoot)); +}); + +test('bytes32 pseudo-root is rejected', () => { + assert.throws( + () => tip5LockRootLimbsFromBase58(base58.encode(new Uint8Array(32))), + error => error instanceof WithdrawalWireError && error.code === 'invalid_lock_root' + ); +}); From 34c8ac256db3ec9120ded723f491ad709f839f01 Mon Sep 17 00:00:00 2001 From: ryjm Date: Tue, 25 Aug 2026 18:34:38 -0700 Subject: [PATCH 05/12] feat(withdrawal): add deterministic JSON driver --- package-lock.json | 4 + package.json | 13 +- scripts/encode-withdrawal-e2e.ts | 398 +++++++++++++++++++++++++++++ test/withdrawal-e2e-driver.test.ts | 258 +++++++++++++++++++ tsconfig.e2e.json | 11 + 5 files changed, 681 insertions(+), 3 deletions(-) create mode 100644 scripts/encode-withdrawal-e2e.ts create mode 100644 test/withdrawal-e2e-driver.test.ts create mode 100644 tsconfig.e2e.json diff --git a/package-lock.json b/package-lock.json index ec0ed50..7f709b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,12 @@ "@nockbox/iris-wasm": "0.2.0", "@scure/base": "^2.0.0" }, + "bin": { + "iris-withdrawal-e2e": "dist/e2e/encode-withdrawal-e2e.js" + }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "0.21.5", "prettier": "^3.4.2", "tsx": "4.23.12", "typescript": "^5.5.3", diff --git a/package.json b/package.json index 95ee6d5..d3e1213 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "iris-withdrawal-e2e": "./dist/e2e/encode-withdrawal-e2e.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -21,15 +24,18 @@ "README.md" ], "scripts": { - "build": "tsc", + "build": "npm run build:sdk && npm run build:e2e-driver", + "build:sdk": "tsc", + "build:e2e-driver": "tsc --project tsconfig.e2e.json --noEmit && esbuild scripts/encode-withdrawal-e2e.ts --bundle --platform=node --format=esm --target=node22 --banner:js='#!/usr/bin/env node' --outfile=dist/e2e/encode-withdrawal-e2e.js", "prepare": "npm run build", "build-example": "npm run build && tsc --project examples/tsconfig.json && vite build --config vite.config.examples.ts", "dev": "tsc --watch", "dev-example": "vite --config vite.config.examples.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "typecheck": "tsc --noEmit", - "test": "tsx --test test/withdrawal.test.ts", + "typecheck": "tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit", + "test": "npm run build --silent && tsx --test test/*.test.ts", + "withdrawal:e2e": "npm run build --silent && node dist/e2e/encode-withdrawal-e2e.js", "prepublishOnly": "npm run build" }, "keywords": [ @@ -43,6 +49,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "0.21.5", "prettier": "^3.4.2", "tsx": "4.23.12", "typescript": "^5.5.3", diff --git a/scripts/encode-withdrawal-e2e.ts b/scripts/encode-withdrawal-e2e.ts new file mode 100644 index 0000000..5807807 --- /dev/null +++ b/scripts/encode-withdrawal-e2e.ts @@ -0,0 +1,398 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { + WITHDRAWAL_POLICY_V1, + WITHDRAWAL_WIRE_V1, + WithdrawalWireError, + decodeWithdrawalWireV1, + encodeWithdrawalWireV1, + resolveWithdrawalDestinationV1, + validateWithdrawalPolicyV1Amount, + validateWithdrawalWireV1, + type WithdrawalDestinationV1, + type WithdrawalWireErrorCode, +} from '../src/withdrawal.js'; +import { initWasm } from '../src/wasm.js'; + +export const WITHDRAWAL_E2E_REQUEST_PROTOCOL = 'iris-withdrawal-e2e-request-v1' as const; +export const WITHDRAWAL_E2E_RESULT_PROTOCOL = 'iris-withdrawal-e2e-result-v1' as const; + +export interface WithdrawalE2eSdkMetadata { + package_name: string; + package_version: string; + revision: string; +} + +export interface WithdrawalE2eRequest { + protocol: typeof WITHDRAWAL_E2E_REQUEST_PROTOCOL; + sdk_metadata: WithdrawalE2eSdkMetadata; + nock_token_address: string; + burner_address: string; + amount_base_units: string; + destination: WithdrawalDestinationV1; + expected: { + wire_protocol: string; + withdrawal_policy: string; + nock_token_address: string; + burner_address: string; + }; +} + +export interface WithdrawalE2eSuccess { + protocol: typeof WITHDRAWAL_E2E_RESULT_PROTOCOL; + ok: true; + sdk_metadata: WithdrawalE2eSdkMetadata; + wire_protocol: string; + withdrawal_policy: string; + selector: string; + destination: { + kind: WithdrawalDestinationV1['kind']; + normalized: string; + lock_root: string; + lock_root_limbs: [string, string, string, string, string]; + }; + amount: { + base_units: string; + nicks: string; + bridge_fee_nicks: string; + net_after_bridge_fee_nicks: string; + }; + commitment: string; + calldata: string; + calldata_byte_length: number; + self_validation: { + valid: true; + decoded_wire_protocol: string; + decoded_amount_base_units: string; + decoded_commitment: string; + decoded_lock_root_limbs: [string, string, string, string, string]; + }; +} + +export type WithdrawalE2eDriverErrorCode = + | WithdrawalWireErrorCode + | 'invalid_json' + | 'invalid_request' + | 'unsupported_protocol' + | 'wire_protocol_mismatch' + | 'withdrawal_policy_mismatch' + | 'internal_error'; + +export interface WithdrawalE2eFailure { + protocol: typeof WITHDRAWAL_E2E_RESULT_PROTOCOL; + ok: false; + error: { + code: WithdrawalE2eDriverErrorCode; + message: string; + }; +} + +export type WithdrawalE2eResponse = WithdrawalE2eSuccess | WithdrawalE2eFailure; + +export class WithdrawalE2eDriverError extends Error { + readonly code: Exclude; + + constructor( + code: Exclude, + message: string + ) { + super(message); + this.name = 'WithdrawalE2eDriverError'; + this.code = code; + Object.setPrototypeOf(this, WithdrawalE2eDriverError.prototype); + } +} + +export function parseWithdrawalE2eRequest(value: unknown): WithdrawalE2eRequest { + const request = requireRecord(value, 'request'); + requireExactKeys( + request, + [ + 'protocol', + 'sdk_metadata', + 'nock_token_address', + 'burner_address', + 'amount_base_units', + 'destination', + 'expected', + ], + 'request' + ); + if (request.protocol !== WITHDRAWAL_E2E_REQUEST_PROTOCOL) { + throw new WithdrawalE2eDriverError( + 'unsupported_protocol', + `protocol must be ${WITHDRAWAL_E2E_REQUEST_PROTOCOL}` + ); + } + if (typeof request.amount_base_units !== 'string') { + throw new WithdrawalWireError( + 'invalid_amount_type', + 'amount_base_units must be an exact decimal string' + ); + } + if (!/^(0|[1-9][0-9]*)$/.test(request.amount_base_units)) { + throw new WithdrawalE2eDriverError( + 'invalid_request', + 'amount_base_units must be an unsigned canonical decimal string' + ); + } + + const sdkMetadata = parseSdkMetadata(request.sdk_metadata); + const destination = requireRecord(request.destination, 'destination'); + requireExactKeys(destination, ['kind', 'value'], 'destination'); + if (destination.kind !== 'v1_pkh' && destination.kind !== 'lock_root') { + throw new WithdrawalE2eDriverError( + 'invalid_request', + 'destination.kind must be v1_pkh or lock_root' + ); + } + const expected = requireRecord(request.expected, 'expected'); + requireExactKeys( + expected, + ['wire_protocol', 'withdrawal_policy', 'nock_token_address', 'burner_address'], + 'expected' + ); + + return { + protocol: WITHDRAWAL_E2E_REQUEST_PROTOCOL, + sdk_metadata: sdkMetadata, + nock_token_address: requireString(request.nock_token_address, 'nock_token_address'), + burner_address: requireString(request.burner_address, 'burner_address'), + amount_base_units: request.amount_base_units, + destination: { + kind: destination.kind, + value: requireString(destination.value, 'destination.value'), + }, + expected: { + wire_protocol: requireString(expected.wire_protocol, 'expected.wire_protocol'), + withdrawal_policy: requireString(expected.withdrawal_policy, 'expected.withdrawal_policy'), + nock_token_address: requireString(expected.nock_token_address, 'expected.nock_token_address'), + burner_address: requireString(expected.burner_address, 'expected.burner_address'), + }, + }; +} + +export async function encodeWithdrawalE2e( + request: WithdrawalE2eRequest +): Promise { + if (request.expected.wire_protocol !== WITHDRAWAL_WIRE_V1.protocol) { + throw new WithdrawalE2eDriverError( + 'wire_protocol_mismatch', + `expected.wire_protocol must be ${WITHDRAWAL_WIRE_V1.protocol}` + ); + } + if (request.expected.withdrawal_policy !== WITHDRAWAL_POLICY_V1.policy) { + throw new WithdrawalE2eDriverError( + 'withdrawal_policy_mismatch', + `expected.withdrawal_policy must be ${WITHDRAWAL_POLICY_V1.policy}` + ); + } + + if (request.destination.kind === 'v1_pkh') { + await initializeNodeWasmWithoutStdout(); + } + const resolved = await withoutStdoutContamination(() => + resolveWithdrawalDestinationV1(request.destination) + ); + const amountBaseUnits = BigInt(request.amount_base_units); + const policy = validateWithdrawalPolicyV1Amount(amountBaseUnits); + const input = { + nockTokenAddress: request.nock_token_address, + burnerAddress: request.burner_address, + amountBaseUnits, + lockRootLimbs: resolved.lockRootLimbs, + }; + const encoded = encodeWithdrawalWireV1(input); + const decoded = decodeWithdrawalWireV1(encoded.calldata); + const validated = validateWithdrawalWireV1(encoded.calldata, { + ...input, + nockTokenAddress: request.expected.nock_token_address, + burnerAddress: request.expected.burner_address, + }); + if (decoded.calldata !== validated.calldata || decoded.commitment !== validated.commitment) { + throw new WithdrawalE2eDriverError( + 'invalid_request', + 'decode and self-validation results disagree' + ); + } + + return { + protocol: WITHDRAWAL_E2E_RESULT_PROTOCOL, + ok: true, + sdk_metadata: request.sdk_metadata, + wire_protocol: WITHDRAWAL_WIRE_V1.protocol, + withdrawal_policy: WITHDRAWAL_POLICY_V1.policy, + selector: encoded.selector, + destination: { + kind: resolved.kind, + normalized: resolved.normalizedDestination, + lock_root: resolved.lockRoot, + lock_root_limbs: stringifyLimbs(resolved.lockRootLimbs), + }, + amount: { + base_units: amountBaseUnits.toString(), + nicks: policy.amountNicks.toString(), + bridge_fee_nicks: policy.bridgeFeeNicks.toString(), + net_after_bridge_fee_nicks: policy.amountAfterBridgeFeeNicks.toString(), + }, + commitment: encoded.commitment, + calldata: encoded.calldata, + calldata_byte_length: (encoded.calldata.length - 2) / 2, + self_validation: { + valid: true, + decoded_wire_protocol: validated.protocol, + decoded_amount_base_units: validated.amountBaseUnits.toString(), + decoded_commitment: validated.commitment, + decoded_lock_root_limbs: stringifyLimbs(validated.lockRootLimbs), + }, + }; +} + +export async function runWithdrawalE2eJson(input: string): Promise { + try { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + throw new WithdrawalE2eDriverError( + 'invalid_json', + 'stdin must contain one valid JSON object' + ); + } + return await encodeWithdrawalE2e(parseWithdrawalE2eRequest(parsed)); + } catch (error) { + return failureResponse(error); + } +} + +async function initializeNodeWasmWithoutStdout(): Promise { + const wasmUrl = import.meta.resolve('@nockbox/iris-wasm/iris_wasm_bg.wasm'); + const wasmBytes = await readFile(fileURLToPath(wasmUrl)); + await withoutStdoutContamination(() => initWasm({ module_or_path: wasmBytes })); +} + +async function withoutStdoutContamination(action: () => Promise): Promise { + const originalLog = console.log; + const originalInfo = console.info; + console.log = (...values: unknown[]) => console.error(...values); + console.info = (...values: unknown[]) => console.error(...values); + try { + return await action(); + } finally { + console.log = originalLog; + console.info = originalInfo; + } +} + +function parseSdkMetadata(value: unknown): WithdrawalE2eSdkMetadata { + const metadata = requireRecord(value, 'sdk_metadata'); + requireExactKeys(metadata, ['package_name', 'package_version', 'revision'], 'sdk_metadata'); + const packageName = requireString(metadata.package_name, 'sdk_metadata.package_name'); + const packageVersion = requireString(metadata.package_version, 'sdk_metadata.package_version'); + const revision = requireString(metadata.revision, 'sdk_metadata.revision'); + if (packageName !== '@nockbox/iris-sdk' || !/^[0-9a-f]{40}$/.test(revision)) { + throw new WithdrawalE2eDriverError( + 'invalid_request', + 'sdk_metadata must identify @nockbox/iris-sdk at a full lowercase git revision' + ); + } + return { + package_name: packageName, + package_version: packageVersion, + revision, + }; +} + +function failureResponse(error: unknown): WithdrawalE2eFailure { + if (error instanceof WithdrawalWireError || error instanceof WithdrawalE2eDriverError) { + return { + protocol: WITHDRAWAL_E2E_RESULT_PROTOCOL, + ok: false, + error: { code: error.code, message: error.message }, + }; + } + return { + protocol: WITHDRAWAL_E2E_RESULT_PROTOCOL, + ok: false, + error: { code: 'internal_error', message: 'withdrawal driver failed unexpectedly' }, + }; +} + +function requireRecord(value: unknown, field: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new WithdrawalE2eDriverError('invalid_request', `${field} must be an object`); + } + return value as Record; +} + +function requireExactKeys( + value: Record, + expected: readonly string[], + field: string +): void { + const unknown = Object.keys(value).filter(key => !expected.includes(key)); + const missing = expected.filter(key => !(key in value)); + if (unknown.length > 0 || missing.length > 0) { + throw new WithdrawalE2eDriverError( + 'invalid_request', + `${field} keys are invalid (unknown=${unknown.sort().join(',') || 'none'}; missing=${missing.join(',') || 'none'})` + ); + } +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WithdrawalE2eDriverError('invalid_request', `${field} must be a non-empty string`); + } + return value; +} + +function stringifyLimbs(limbs: readonly bigint[]): [string, string, string, string, string] { + if (limbs.length !== 5) { + throw new WithdrawalE2eDriverError('invalid_request', 'lock root must contain five limbs'); + } + return [ + limbs[0].toString(), + limbs[1].toString(), + limbs[2].toString(), + limbs[3].toString(), + limbs[4].toString(), + ]; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function runWithdrawalE2eCli(): Promise { + const response = await runWithdrawalE2eJson(await readStdin()); + process.stdout.write(`${JSON.stringify(response)}\n`); + if (!response.ok) { + process.stderr.write(`iris withdrawal E2E driver: ${response.error.code}\n`); + } + return response.ok ? 0 : 1; +} + +const entrypoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +if (entrypoint === import.meta.url) { + runWithdrawalE2eCli() + .then(exitCode => { + process.exitCode = exitCode; + }) + .catch(() => { + const response: WithdrawalE2eFailure = { + protocol: WITHDRAWAL_E2E_RESULT_PROTOCOL, + ok: false, + error: { code: 'internal_error', message: 'withdrawal driver failed unexpectedly' }, + }; + process.stdout.write(`${JSON.stringify(response)}\n`); + process.stderr.write('iris withdrawal E2E driver: internal_error\n'); + process.exitCode = 1; + }); +} diff --git a/test/withdrawal-e2e-driver.test.ts b/test/withdrawal-e2e-driver.test.ts new file mode 100644 index 0000000..faeff1c --- /dev/null +++ b/test/withdrawal-e2e-driver.test.ts @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { base58 } from '@scure/base'; + +import { + WITHDRAWAL_E2E_REQUEST_PROTOCOL, + WITHDRAWAL_E2E_RESULT_PROTOCOL, + encodeWithdrawalE2e, + parseWithdrawalE2eRequest, + runWithdrawalE2eJson, + type WithdrawalE2eRequest, +} from '../scripts/encode-withdrawal-e2e.js'; +import { + WITHDRAWAL_POLICY_V1, + WITHDRAWAL_WIRE_V1, + type WithdrawalWireErrorCode, +} from '../src/withdrawal.js'; + +interface ValidFixture { + nock_token_address: string; + burner_address: string; + amount_base_units: string; + lock_root_limbs: [string, string, string, string, string]; + commitment: string; + calldata: string; +} + +interface WireFixture { + valid_vectors: ValidFixture[]; +} + +const fixture = JSON.parse( + readFileSync(new URL('../test-fixtures/withdrawal_wire_v1_vectors.json', import.meta.url), 'utf8') +) as WireFixture; +const canonical = fixture.valid_vectors[0]; + +function directRootRequest(): WithdrawalE2eRequest { + return { + protocol: WITHDRAWAL_E2E_REQUEST_PROTOCOL, + sdk_metadata: { + package_name: '@nockbox/iris-sdk', + package_version: '0.3.0', + revision: '0123456789abcdef0123456789abcdef01234567', + }, + nock_token_address: canonical.nock_token_address, + burner_address: canonical.burner_address, + amount_base_units: canonical.amount_base_units, + destination: { + kind: 'lock_root', + value: lockRootFromLimbs(canonical.lock_root_limbs), + }, + expected: { + wire_protocol: WITHDRAWAL_WIRE_V1.protocol, + withdrawal_policy: WITHDRAWAL_POLICY_V1.policy, + nock_token_address: canonical.nock_token_address, + burner_address: canonical.burner_address, + }, + }; +} + +function lockRootFromLimbs(limbs: readonly string[]): string { + const bytes = new Uint8Array(40); + const view = new DataView(bytes.buffer); + for (let index = 0; index < limbs.length; index += 1) { + view.setBigUint64(index * 8, BigInt(limbs[index]), false); + } + return base58.encode(bytes); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +async function expectDriverError( + action: () => Promise, + code: WithdrawalWireErrorCode | string +): Promise { + await assert.rejects(action, (error: unknown) => hasErrorCode(error, code)); +} + +test('direct-root driver output matches the canonical Rust fixture exactly', async () => { + const request = directRootRequest(); + const result = await encodeWithdrawalE2e(request); + + assert.equal(result.protocol, WITHDRAWAL_E2E_RESULT_PROTOCOL); + assert.equal(result.calldata, canonical.calldata); + assert.equal(result.commitment, canonical.commitment); + assert.equal(result.calldata_byte_length, 116); + assert.equal(result.destination.lock_root, request.destination.value); + assert.deepEqual(result.destination.lock_root_limbs, canonical.lock_root_limbs); + assert.equal(result.amount.base_units, canonical.amount_base_units); + assert.equal( + result.amount.nicks, + (BigInt(canonical.amount_base_units) / WITHDRAWAL_POLICY_V1.baseUnitsPerNick).toString() + ); + assert.equal(result.self_validation.valid, true); + assert.equal(result.self_validation.decoded_amount_base_units, canonical.amount_base_units); + assert.deepEqual(result.self_validation.decoded_lock_root_limbs, canonical.lock_root_limbs); + + for (const value of Object.values(result.amount)) { + assert.equal(typeof value, 'string'); + } + assert.ok(result.destination.lock_root_limbs.every(value => typeof value === 'string')); + assert.equal(JSON.stringify(await encodeWithdrawalE2e(request)), JSON.stringify(result)); +}); + +test('v1 PKH resolves through Node WASM without changing the machine protocol', async () => { + const request = directRootRequest(); + request.destination = { + kind: 'v1_pkh', + value: 'AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh', + }; + + const result = await encodeWithdrawalE2e(request); + assert.equal(result.ok, true); + assert.equal(result.destination.kind, 'v1_pkh'); + assert.equal(base58.decode(result.destination.lock_root).length, 40); + assert.equal(result.calldata_byte_length, WITHDRAWAL_WIRE_V1.calldataLength); + assert.equal(result.self_validation.valid, true); +}); + +test('strict request parser rejects malformed shape and numeric financial input', () => { + const request = directRootRequest(); + assert.throws( + () => parseWithdrawalE2eRequest({ ...request, unexpected: true }), + error => hasErrorCode(error, 'invalid_request') + ); + assert.throws( + () => parseWithdrawalE2eRequest({ ...request, amount_base_units: 1_000_000 }), + error => hasErrorCode(error, 'invalid_amount_type') + ); + assert.throws( + () => parseWithdrawalE2eRequest({ ...request, amount_base_units: '01' }), + error => hasErrorCode(error, 'invalid_request') + ); +}); + +test('driver preserves official destination, amount, and binding rejection codes', async () => { + const invalidRoot = directRootRequest(); + invalidRoot.destination = { kind: 'lock_root', value: base58.encode(new Uint8Array(32)) }; + await expectDriverError(() => encodeWithdrawalE2e(invalidRoot), 'invalid_lock_root'); + + const outOfFieldBytes = new Uint8Array(40); + new DataView(outOfFieldBytes.buffer).setBigUint64(0, WITHDRAWAL_WIRE_V1.tip5Prime, false); + const outOfField = directRootRequest(); + outOfField.destination = { kind: 'lock_root', value: base58.encode(outOfFieldBytes) }; + await expectDriverError(() => encodeWithdrawalE2e(outOfField), 'invalid_lock_root'); + + const invalidAddress = directRootRequest(); + invalidAddress.nock_token_address = '0x1234'; + await expectDriverError(() => encodeWithdrawalE2e(invalidAddress), 'invalid_address'); + + const zeroAmount = directRootRequest(); + zeroAmount.amount_base_units = '0'; + await expectDriverError(() => encodeWithdrawalE2e(zeroAmount), 'amount_not_positive'); + + const nonDivisible = directRootRequest(); + nonDivisible.amount_base_units = (WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits + 1n).toString(); + await expectDriverError(() => encodeWithdrawalE2e(nonDivisible), 'amount_not_divisible'); + + const belowMinimum = directRootRequest(); + belowMinimum.amount_base_units = ( + WITHDRAWAL_POLICY_V1.minimumGrossBaseUnits - WITHDRAWAL_POLICY_V1.baseUnitsPerNick + ).toString(); + await expectDriverError(() => encodeWithdrawalE2e(belowMinimum), 'amount_below_minimum'); + + const overflow = directRootRequest(); + overflow.amount_base_units = ( + (WITHDRAWAL_POLICY_V1.maximumNicks + 1n) * + WITHDRAWAL_POLICY_V1.baseUnitsPerNick + ).toString(); + await expectDriverError(() => encodeWithdrawalE2e(overflow), 'amount_overflow'); + + const wrongToken = directRootRequest(); + wrongToken.expected.nock_token_address = '0x3333333333333333333333333333333333333333'; + await expectDriverError(() => encodeWithdrawalE2e(wrongToken), 'calldata_commitment_mismatch'); + + const wrongBurner = directRootRequest(); + wrongBurner.expected.burner_address = '0x4444444444444444444444444444444444444444'; + await expectDriverError(() => encodeWithdrawalE2e(wrongBurner), 'calldata_commitment_mismatch'); +}); + +test('driver rejects stale wire and policy identifiers before encoding', async () => { + const wrongWire = directRootRequest(); + wrongWire.expected.wire_protocol = 'WithdrawalWireV0'; + await expectDriverError(() => encodeWithdrawalE2e(wrongWire), 'wire_protocol_mismatch'); + + const wrongPolicy = directRootRequest(); + wrongPolicy.expected.withdrawal_policy = 'withdrawal-policy-v0'; + await expectDriverError(() => encodeWithdrawalE2e(wrongPolicy), 'withdrawal_policy_mismatch'); +}); + +test('JSON CLI emits exactly one response object and keeps diagnostics off stdout', () => { + const script = fileURLToPath(new URL('../dist/e2e/encode-withdrawal-e2e.js', import.meta.url)); + const requestJson = JSON.stringify(directRootRequest()); + const run = spawnSync(process.execPath, [script], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + input: requestJson, + encoding: 'utf8', + }); + + assert.equal(run.status, 0, run.stderr); + assert.equal(run.stderr, ''); + assert.equal(run.stdout.split('\n').filter(Boolean).length, 1); + const response = JSON.parse(run.stdout) as Record; + assert.equal(response.protocol, WITHDRAWAL_E2E_RESULT_PROTOCOL); + assert.equal(response.ok, true); + assert.equal(response.calldata, canonical.calldata); + + const pkhRequest = directRootRequest(); + pkhRequest.destination = { + kind: 'v1_pkh', + value: 'AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh', + }; + const wasmRun = spawnSync(process.execPath, [script], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + input: JSON.stringify(pkhRequest), + encoding: 'utf8', + }); + assert.equal(wasmRun.status, 0, wasmRun.stderr); + assert.equal(wasmRun.stderr, ''); + assert.equal(wasmRun.stdout.split('\n').filter(Boolean).length, 1); + const wasmResponse = JSON.parse(wasmRun.stdout) as { + ok: boolean; + destination: { kind: string }; + }; + assert.equal(wasmResponse.ok, true); + assert.equal(wasmResponse.destination.kind, 'v1_pkh'); + + const malformed = spawnSync(process.execPath, [script], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + input: '{not-json', + encoding: 'utf8', + }); + assert.equal(malformed.status, 1); + assert.equal(malformed.stdout.split('\n').filter(Boolean).length, 1); + const failure = JSON.parse(malformed.stdout) as { + ok: boolean; + error: { code: string }; + }; + assert.equal(failure.ok, false); + assert.equal(failure.error.code, 'invalid_json'); + assert.match(malformed.stderr, /invalid_json/); +}); + +test('JSON API returns structured errors without echoing malformed input', async () => { + const malformed = await runWithdrawalE2eJson('{"secret":"do-not-echo"'); + assert.equal(malformed.ok, false); + if (malformed.ok) { + assert.fail('malformed response unexpectedly succeeded'); + } + assert.equal(malformed.error.code, 'invalid_json'); + assert.doesNotMatch(JSON.stringify(malformed), /do-not-echo/); +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..6b0351e --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist/e2e", + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["scripts/encode-withdrawal-e2e.ts"] +} From f9ca3f1270a5dd6e5b58bd547793aa69a724a55f Mon Sep 17 00:00:00 2001 From: ryjm Date: Tue, 25 Aug 2026 19:21:59 -0700 Subject: [PATCH 06/12] feat(withdrawal): package immutable E2E artifacts --- package.json | 2 + scripts/prepare-withdrawal-e2e-package.ts | 374 ++++++++++++++++++++++ test/e2e-package.test.ts | 238 ++++++++++++++ tsconfig.e2e.json | 2 +- 4 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 scripts/prepare-withdrawal-e2e-package.ts create mode 100644 test/e2e-package.test.ts diff --git a/package.json b/package.json index d3e1213..29f311b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit", "test": "npm run build --silent && tsx --test test/*.test.ts", + "test:pack-readiness": "npm run build --silent && tsx --test test/withdrawal.test.ts test/withdrawal-e2e-driver.test.ts", "withdrawal:e2e": "npm run build --silent && node dist/e2e/encode-withdrawal-e2e.js", + "package:withdrawal-e2e": "tsx scripts/prepare-withdrawal-e2e-package.ts", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/prepare-withdrawal-e2e-package.ts b/scripts/prepare-withdrawal-e2e-package.ts new file mode 100644 index 0000000..27d86a8 --- /dev/null +++ b/scripts/prepare-withdrawal-e2e-package.ts @@ -0,0 +1,374 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const EXPECTED_PACKAGE_NAME = '@nockbox/iris-sdk'; +const DRIVER_PATH = 'dist/e2e/encode-withdrawal-e2e.js'; +const METADATA_SCHEMA_VERSION = 1; +const execFileAsync = promisify(execFile); + +export interface PackedFileFacts { + path: string; + size: number; + mode: number; +} + +export interface IrisE2ePackageMetadata { + schema_version: 1; + package_name: string; + package_version: string; + git_revision: string; + tarball_path: string; + tarball_sha256: string; + npm_integrity: string; + npm_shasum: string; + driver_path: typeof DRIVER_PATH; + files: PackedFileFacts[]; + node_version: string; + npm_version: string; +} + +export interface PrepareIrisPackageOptions { + checkout: string; + output_dir: string; + expected_revision?: string; + expected_version?: string; +} + +interface NpmPackResult { + name: string; + version: string; + filename: string; + shasum: string; + integrity: string; + files: PackedFileFacts[]; +} + +export async function prepareIrisE2ePackage( + options: PrepareIrisPackageOptions +): Promise { + const checkout = resolve(options.checkout); + const outputDir = resolve(options.output_dir); + const packageJsonPath = resolve(checkout, 'package.json'); + const packageJson = parsePackageJson(await readFile(packageJsonPath, 'utf8')); + if (packageJson.name !== EXPECTED_PACKAGE_NAME) { + throw new Error(`package name must be ${EXPECTED_PACKAGE_NAME}`); + } + if (options.expected_version && packageJson.version !== options.expected_version) { + throw new Error( + `package version mismatch: expected ${options.expected_version}, observed ${packageJson.version}` + ); + } + if (isWithin(checkout, outputDir)) { + throw new Error('output directory must be outside the Iris checkout'); + } + + const statusBefore = await run( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all'], + checkout + ); + if (statusBefore.stdout.trim().length > 0) { + throw new Error('Iris checkout must be clean before packaging'); + } + const revision = ( + await run('git', ['rev-parse', '--verify', 'HEAD^{commit}'], checkout) + ).stdout.trim(); + if (!/^[0-9a-f]{40}$/.test(revision)) { + throw new Error('Iris checkout did not resolve to a full git revision'); + } + if (options.expected_revision && revision !== options.expected_revision) { + throw new Error( + `git revision mismatch: expected ${options.expected_revision}, observed ${revision}` + ); + } + + const nodeVersion = process.version; + const npmVersion = (await run('npm', ['--version'], checkout)).stdout.trim(); + requireSupportedToolchain(nodeVersion, npmVersion); + await run('npm', ['run', 'test:pack-readiness', '--silent', '--offline'], checkout); + await mkdir(outputDir, { recursive: true }); + + const expectedFilename = `${packageJson.name.replace(/^@/, '').replace('/', '-')}-${packageJson.version}.tgz`; + const expectedTarball = resolve(outputDir, expectedFilename); + const expectedMetadata = `${expectedTarball}.metadata.json`; + if (await pathExists(expectedTarball)) { + throw new Error(`refusing to overwrite existing tarball ${expectedTarball}`); + } + if (await pathExists(expectedMetadata)) { + throw new Error(`refusing to overwrite existing metadata ${expectedMetadata}`); + } + + const packed = await run( + 'npm', + ['pack', '--silent', '--offline', '--json', '--pack-destination', outputDir], + checkout + ); + const result = parsePackResult(packed.stdout); + if (result.name !== packageJson.name || result.version !== packageJson.version) { + throw new Error('npm pack returned a different package identity'); + } + const tarballPath = resolve(outputDir, result.filename); + if (dirname(tarballPath) !== outputDir || tarballPath !== expectedTarball) { + throw new Error('npm pack returned an unexpected tarball path'); + } + validatePackedFiles(result.files); + if (!result.integrity.startsWith('sha512-') || !/^[0-9a-f]{40}$/.test(result.shasum)) { + throw new Error('npm pack returned malformed integrity metadata'); + } + + const statusAfter = await run( + 'git', + ['status', '--porcelain=v1', '--untracked-files=all'], + checkout + ); + if (statusAfter.stdout.trim().length > 0) { + throw new Error('Iris packaging lifecycle modified the checkout'); + } + const metadata: IrisE2ePackageMetadata = { + schema_version: METADATA_SCHEMA_VERSION, + package_name: result.name, + package_version: result.version, + git_revision: revision, + tarball_path: tarballPath, + tarball_sha256: await sha256File(tarballPath), + npm_integrity: result.integrity, + npm_shasum: result.shasum, + driver_path: DRIVER_PATH, + files: [...result.files].sort((left, right) => left.path.localeCompare(right.path)), + node_version: nodeVersion, + npm_version: npmVersion, + }; + await writeFile(expectedMetadata, `${JSON.stringify(metadata, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + return metadata; +} + +function parsePackageJson(raw: string): { name: string; version: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('Iris package.json is malformed'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Iris package.json must be an object'); + } + const value = parsed as Record; + if (typeof value.name !== 'string' || typeof value.version !== 'string') { + throw new Error('Iris package.json must contain string name and version'); + } + return { name: value.name, version: value.version }; +} + +function parsePackResult(stdout: string): NpmPackResult { + let parsed: unknown; + for (let index = stdout.indexOf('['); index >= 0; index = stdout.indexOf('[', index + 1)) { + try { + parsed = JSON.parse(stdout.slice(index)); + break; + } catch { + // npm lifecycle output may precede the final JSON array. + } + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + throw new Error('npm pack did not return one JSON package result'); + } + const result = parsed[0] as Partial; + if ( + typeof result.name !== 'string' || + typeof result.version !== 'string' || + typeof result.filename !== 'string' || + typeof result.shasum !== 'string' || + typeof result.integrity !== 'string' || + !Array.isArray(result.files) + ) { + throw new Error('npm pack result is missing identity or file metadata'); + } + const files = result.files.map((entry, index) => { + if ( + typeof entry !== 'object' || + entry === null || + typeof entry.path !== 'string' || + typeof entry.size !== 'number' || + typeof entry.mode !== 'number' + ) { + throw new Error(`npm pack file metadata ${index} is malformed`); + } + return { path: entry.path, size: entry.size, mode: entry.mode }; + }); + return { + name: result.name, + version: result.version, + filename: result.filename, + shasum: result.shasum, + integrity: result.integrity, + files, + }; +} + +function validatePackedFiles(files: readonly PackedFileFacts[]): void { + const paths = new Set(); + for (const file of files) { + const normalized = file.path.split('\\').join('/'); + const lower = normalized.toLowerCase(); + if ( + normalized !== file.path || + normalized.startsWith('/') || + normalized.split('/').includes('..') || + file.size < 0 || + !Number.isSafeInteger(file.size) || + !Number.isSafeInteger(file.mode) + ) { + throw new Error(`unsafe npm package entry ${file.path}`); + } + if (!paths.add(normalized)) { + throw new Error(`duplicate npm package entry ${normalized}`); + } + const allowed = + normalized === 'LICENSE' || + normalized === 'README.md' || + normalized === 'package.json' || + normalized.startsWith('dist/') || + normalized.startsWith('test-fixtures/'); + const sensitive = + lower.includes('.env') || + lower.endsWith('.pem') || + lower.endsWith('.key') || + lower.endsWith('.p12') || + lower.endsWith('.npmrc') || + lower.includes('credential') || + lower.includes('secret'); + if (!allowed || sensitive) { + throw new Error(`unexpected or sensitive npm package entry ${normalized}`); + } + } + if (!paths.has(DRIVER_PATH) || !paths.has('package.json')) { + throw new Error('npm package is missing the bundled withdrawal driver or package.json'); + } +} + +function requireSupportedToolchain(nodeVersion: string, npmVersion: string): void { + const nodeMajor = Number.parseInt(nodeVersion.replace(/^v/, '').split('.')[0], 10); + const npmMajor = Number.parseInt(npmVersion.split('.')[0], 10); + if (!Number.isSafeInteger(nodeMajor) || nodeMajor < 22) { + throw new Error(`Node 22 or newer is required, observed ${nodeVersion}`); + } + if (!Number.isSafeInteger(npmMajor) || npmMajor < 10) { + throw new Error(`npm 10 or newer is required, observed ${npmVersion}`); + } +} + +function isWithin(parent: string, child: string): boolean { + const path = relative(parent, child); + return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function sha256File(path: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) { + hash.update(chunk); + } + return hash.digest('hex'); +} + +async function run( + program: string, + args: readonly string[], + cwd: string +): Promise<{ stdout: string; stderr: string }> { + try { + const result = await execFileAsync(program, [...args], { + cwd, + env: { + ...process.env, + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', + }, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (error) { + const stderr = + typeof error === 'object' && + error !== null && + 'stderr' in error && + typeof error.stderr === 'string' + ? error.stderr.trim() + : 'no stderr'; + throw new Error(`${program} ${args.join(' ')} failed: ${stderr}`); + } +} + +function parseArgs(argv: readonly string[]): PrepareIrisPackageOptions { + const defaultCheckout = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + let checkout = defaultCheckout; + let outputDir: string | undefined; + let expectedRevision: string | undefined; + let expectedVersion: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value`); + } + switch (flag) { + case '--checkout': + checkout = value; + break; + case '--output-dir': + outputDir = value; + break; + case '--expected-revision': + expectedRevision = value; + break; + case '--expected-version': + expectedVersion = value; + break; + default: + throw new Error(`unknown argument ${flag}`); + } + index += 1; + } + if (!outputDir) { + throw new Error('--output-dir is required'); + } + return { + checkout, + output_dir: outputDir, + expected_revision: expectedRevision, + expected_version: expectedVersion, + }; +} + +const entrypoint = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (entrypoint === fileURLToPath(import.meta.url)) { + prepareIrisE2ePackage(parseArgs(process.argv.slice(2))) + .then(metadata => { + process.stdout.write(`${JSON.stringify(metadata)}\n`); + }) + .catch(error => { + process.stderr.write(`iris E2E package preparation failed: ${String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/test/e2e-package.test.ts b/test/e2e-package.test.ts new file mode 100644 index 0000000..aa1f45a --- /dev/null +++ b/test/e2e-package.test.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { + prepareIrisE2ePackage, + type IrisE2ePackageMetadata, +} from '../scripts/prepare-withdrawal-e2e-package.js'; + +let fixtureSequence = 0; + +interface FixtureOptions { + packageName?: string; + readinessFails?: boolean; + includeSecret?: boolean; +} + +test('clean checkout packs deterministically with immutable identity and executable driver', async () => { + const fixture = createFixture('clean package with spaces'); + const first = await prepareIrisE2ePackage({ + checkout: fixture.checkout, + output_dir: join(fixture.root, 'first output'), + expected_revision: fixture.revision, + expected_version: '0.3.0', + }); + const second = await prepareIrisE2ePackage({ + checkout: fixture.checkout, + output_dir: join(fixture.root, 'second output'), + }); + + assert.equal(first.schema_version, 1); + assert.equal(first.package_name, '@nockbox/iris-sdk'); + assert.equal(first.package_version, '0.3.0'); + assert.equal(first.git_revision, fixture.revision); + assert.match(first.tarball_sha256, /^[0-9a-f]{64}$/); + assert.match(first.npm_integrity, /^sha512-/); + assert.match(first.npm_shasum, /^[0-9a-f]{40}$/); + assert.equal(first.driver_path, 'dist/e2e/encode-withdrawal-e2e.js'); + assert.ok(first.files.some(file => file.path === first.driver_path)); + assert.ok(first.files.every(file => allowedPackagePath(file.path))); + assert.equal(first.tarball_sha256, second.tarball_sha256); + assert.equal(first.npm_integrity, second.npm_integrity); + assert.equal(sha256(readFileSync(first.tarball_path)), first.tarball_sha256); + assert.ok(existsSync(`${first.tarball_path}.metadata.json`)); + + const extractedRoot = join(fixture.root, 'extracted driver'); + mkdirSync(extractedRoot, { recursive: true }); + run('tar', ['-xzf', first.tarball_path, '-C', extractedRoot, `package/${first.driver_path}`]); + const driver = join(extractedRoot, 'package', first.driver_path); + const execution = spawnSync(process.execPath, [driver], { + input: '{"probe":true}', + encoding: 'utf8', + }); + assert.equal(execution.status, 0, execution.stderr); + assert.deepEqual(JSON.parse(execution.stdout), { + ok: true, + request: { probe: true }, + }); +}); + +test('package CLI emits one metadata object and accepts explicit checkout and output paths', () => { + const fixture = createFixture('cli package'); + const script = fileURLToPath( + new URL('../scripts/prepare-withdrawal-e2e-package.ts', import.meta.url) + ); + const outputDir = join(fixture.root, 'cli output'); + const execution = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + script, + '--checkout', + fixture.checkout, + '--output-dir', + outputDir, + '--expected-revision', + fixture.revision, + '--expected-version', + '0.3.0', + ], + { encoding: 'utf8' } + ); + assert.equal(execution.status, 0, execution.stderr); + assert.equal(execution.stderr, ''); + assert.equal(execution.stdout.split('\n').filter(Boolean).length, 1); + const metadata = JSON.parse(execution.stdout) as IrisE2ePackageMetadata; + assert.equal(metadata.git_revision, fixture.revision); + assert.equal(metadata.tarball_path.startsWith(outputDir), true); +}); + +test('dirty and wrong checkouts are rejected before package creation', async () => { + const dirty = createFixture('dirty package'); + writeFileSync(join(dirty.checkout, 'README.md'), 'dirty\n'); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: dirty.checkout, + output_dir: join(dirty.root, 'output'), + }), + /must be clean/ + ); + + const wrong = createFixture('wrong package', { packageName: '@example/not-iris' }); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: wrong.checkout, + output_dir: join(wrong.root, 'output'), + }), + /package name/ + ); +}); + +test('unexpected sensitive files and lifecycle failures are rejected', async () => { + const secret = createFixture('secret package', { includeSecret: true }); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: secret.checkout, + output_dir: join(secret.root, 'output'), + }), + /unexpected or sensitive/ + ); + + const failing = createFixture('failing lifecycle', { readinessFails: true }); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: failing.checkout, + output_dir: join(failing.root, 'output'), + }), + /test:pack-readiness/ + ); +}); + +test('revision and version expectations distinguish same-version checkouts', async () => { + const fixture = createFixture('identity package'); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: fixture.checkout, + output_dir: join(fixture.root, 'wrong revision'), + expected_revision: 'f'.repeat(40), + }), + /git revision mismatch/ + ); + await assert.rejects( + () => + prepareIrisE2ePackage({ + checkout: fixture.checkout, + output_dir: join(fixture.root, 'wrong version'), + expected_version: '9.9.9', + }), + /package version mismatch/ + ); +}); + +function createFixture(label: string, options: FixtureOptions = {}) { + fixtureSequence += 1; + const root = join( + process.env.TMPDIR ?? '/tmp', + `iris-e2e-package-${process.pid}-${Date.now()}-${fixtureSequence}-${label}` + ); + const checkout = join(root, 'checkout'); + mkdirSync(join(checkout, 'dist', 'e2e'), { recursive: true }); + mkdirSync(join(checkout, 'test-fixtures'), { recursive: true }); + const packageJson = { + name: options.packageName ?? '@nockbox/iris-sdk', + version: '0.3.0', + type: 'module', + bin: { + 'iris-withdrawal-e2e': './dist/e2e/encode-withdrawal-e2e.js', + }, + files: ['dist', 'test-fixtures', 'README.md'], + scripts: { + 'test:pack-readiness': options.readinessFails + ? 'node -e "process.exit(7)"' + : 'node -e "process.exit(0)"', + prepare: 'node -e "process.exit(0)"', + }, + }; + writeFileSync(join(checkout, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + writeFileSync(join(checkout, 'README.md'), 'fixture\n'); + writeFileSync(join(checkout, 'LICENSE'), 'fixture license\n'); + writeFileSync(join(checkout, 'test-fixtures', 'vector.json'), '{"fixture":true}\n'); + writeFileSync( + join(checkout, 'dist', 'e2e', 'encode-withdrawal-e2e.js'), + [ + '#!/usr/bin/env node', + "let input = '';", + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', chunk => { input += chunk; });", + "process.stdin.on('end', () => {", + ' process.stdout.write(`${JSON.stringify({ ok: true, request: JSON.parse(input) })}\\n`);', + '});', + '', + ].join('\n') + ); + if (options.includeSecret) { + writeFileSync(join(checkout, 'dist', 'secret.pem'), 'not-a-real-secret\n'); + } + run('git', ['init', '--quiet'], checkout); + run('git', ['config', 'user.name', 'Iris E2E Fixture'], checkout); + run('git', ['config', 'user.email', 'fixture@example.invalid'], checkout); + run('git', ['add', '.'], checkout); + run('git', ['-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'fixture'], checkout); + const revision = run('git', ['rev-parse', 'HEAD'], checkout).stdout.trim(); + return { root, checkout, revision }; +} + +function run(program: string, args: readonly string[], cwd?: string) { + const result = spawnSync(program, [...args], { + cwd, + encoding: 'utf8', + shell: false, + }); + assert.equal(result.status, 0, result.stderr); + return result; +} + +function allowedPackagePath(path: string): boolean { + return ( + path === 'LICENSE' || + path === 'README.md' || + path === 'package.json' || + path.startsWith('dist/') || + path.startsWith('test-fixtures/') + ); +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 6b0351e..4f1f2d4 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -7,5 +7,5 @@ "declarationMap": false, "sourceMap": false }, - "include": ["scripts/encode-withdrawal-e2e.ts"] + "include": ["scripts/*.ts"] } From 65ca0ec0a90dcb31860eda40df335c03b3ebaf1f Mon Sep 17 00:00:00 2001 From: ryjm Date: Wed, 26 Aug 2026 02:55:44 -0700 Subject: [PATCH 07/12] fix(package): expose Node-compatible withdrawal codec --- package-lock.json | 4 ++-- package.json | 6 +++++- src/wasm.ts | 7 +++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f709b0..9217358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@noble/hashes": "2.3.0", diff --git a/package.json b/package.json index 29f311b..e9b2b97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.0", + "version": "0.3.1", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", @@ -16,6 +16,10 @@ "./wasm": { "types": "./dist/wasm.d.ts", "import": "./dist/wasm.js" + }, + "./withdrawal": { + "types": "./dist/withdrawal.d.ts", + "import": "./dist/withdrawal.js" } }, "files": [ diff --git a/src/wasm.ts b/src/wasm.ts index 966c8ff..971cad3 100644 --- a/src/wasm.ts +++ b/src/wasm.ts @@ -2,11 +2,10 @@ * WASM module exports */ -export * from '@nockbox/iris-wasm/iris_wasm'; -export { default } from '@nockbox/iris-wasm/iris_wasm'; -export * as guard from '@nockbox/iris-wasm/iris_wasm.guard'; +export * from '@nockbox/iris-wasm/iris_wasm.js'; +export { default } from '@nockbox/iris-wasm/iris_wasm.js'; -import init from '@nockbox/iris-wasm/iris_wasm'; +import init from '@nockbox/iris-wasm/iris_wasm.js'; /** * Canonical initializer re-export for SDK consumers. From b7d28d3e1481544dec440ee1b103da1c5f1f17d2 Mon Sep 17 00:00:00 2001 From: ryjm Date: Wed, 26 Aug 2026 08:19:03 -0700 Subject: [PATCH 08/12] fix: package self-contained withdrawal driver --- package-lock.json | 4 ++-- package.json | 2 +- scripts/encode-withdrawal-e2e.ts | 24 +++++++++++++++++++---- scripts/prepare-withdrawal-e2e-package.ts | 17 +++++++++++++--- test/e2e-package.test.ts | 2 ++ 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9217358..f0fe7c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "@noble/hashes": "2.3.0", diff --git a/package.json b/package.json index e9b2b97..41b5217 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", diff --git a/scripts/encode-withdrawal-e2e.ts b/scripts/encode-withdrawal-e2e.ts index 5807807..5bae3ae 100644 --- a/scripts/encode-withdrawal-e2e.ts +++ b/scripts/encode-withdrawal-e2e.ts @@ -1,5 +1,6 @@ +import { realpathSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { resolve } from 'node:path'; import { @@ -268,8 +269,21 @@ export async function runWithdrawalE2eJson(input: string): Promise { - const wasmUrl = import.meta.resolve('@nockbox/iris-wasm/iris_wasm_bg.wasm'); - const wasmBytes = await readFile(fileURLToPath(wasmUrl)); + let wasmBytes: Buffer; + try { + wasmBytes = await readFile(new URL('./iris_wasm_bg.wasm', import.meta.url)); + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } + const dependencyUrl = import.meta.resolve('@nockbox/iris-wasm/iris_wasm_bg.wasm'); + wasmBytes = await readFile(new URL(dependencyUrl)); + } await withoutStdoutContamination(() => initWasm({ module_or_path: wasmBytes })); } @@ -379,7 +393,9 @@ export async function runWithdrawalE2eCli(): Promise { return response.ok ? 0 : 1; } -const entrypoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +const entrypoint = process.argv[1] + ? pathToFileURL(realpathSync(resolve(process.argv[1]))).href + : undefined; if (entrypoint === import.meta.url) { runWithdrawalE2eCli() .then(exitCode => { diff --git a/scripts/prepare-withdrawal-e2e-package.ts b/scripts/prepare-withdrawal-e2e-package.ts index 27d86a8..d233e67 100644 --- a/scripts/prepare-withdrawal-e2e-package.ts +++ b/scripts/prepare-withdrawal-e2e-package.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; const EXPECTED_PACKAGE_NAME = '@nockbox/iris-sdk'; const DRIVER_PATH = 'dist/e2e/encode-withdrawal-e2e.js'; +const WASM_PATH = 'dist/e2e/iris_wasm_bg.wasm'; const METADATA_SCHEMA_VERSION = 1; const execFileAsync = promisify(execFile); @@ -91,6 +92,14 @@ export async function prepareIrisE2ePackage( const npmVersion = (await run('npm', ['--version'], checkout)).stdout.trim(); requireSupportedToolchain(nodeVersion, npmVersion); await run('npm', ['run', 'test:pack-readiness', '--silent', '--offline'], checkout); + const wasmDestination = resolve(checkout, WASM_PATH); + const dependencyWasm = resolve( + checkout, + 'node_modules/@nockbox/iris-wasm/iris_wasm_bg.wasm' + ); + if (!(await pathExists(wasmDestination))) { + await copyFile(dependencyWasm, wasmDestination); + } await mkdir(outputDir, { recursive: true }); const expectedFilename = `${packageJson.name.replace(/^@/, '').replace('/', '-')}-${packageJson.version}.tgz`; @@ -249,8 +258,10 @@ function validatePackedFiles(files: readonly PackedFileFacts[]): void { throw new Error(`unexpected or sensitive npm package entry ${normalized}`); } } - if (!paths.has(DRIVER_PATH) || !paths.has('package.json')) { - throw new Error('npm package is missing the bundled withdrawal driver or package.json'); + if (!paths.has(DRIVER_PATH) || !paths.has(WASM_PATH) || !paths.has('package.json')) { + throw new Error( + 'npm package is missing the bundled withdrawal driver, WASM module, or package.json' + ); } } diff --git a/test/e2e-package.test.ts b/test/e2e-package.test.ts index aa1f45a..f8a7513 100644 --- a/test/e2e-package.test.ts +++ b/test/e2e-package.test.ts @@ -41,6 +41,7 @@ test('clean checkout packs deterministically with immutable identity and executa assert.match(first.npm_shasum, /^[0-9a-f]{40}$/); assert.equal(first.driver_path, 'dist/e2e/encode-withdrawal-e2e.js'); assert.ok(first.files.some(file => file.path === first.driver_path)); + assert.ok(first.files.some(file => file.path === 'dist/e2e/iris_wasm_bg.wasm')); assert.ok(first.files.every(file => allowedPackagePath(file.path))); assert.equal(first.tarball_sha256, second.tarball_sha256); assert.equal(first.npm_integrity, second.npm_integrity); @@ -201,6 +202,7 @@ function createFixture(label: string, options: FixtureOptions = {}) { '', ].join('\n') ); + writeFileSync(join(checkout, 'dist', 'e2e', 'iris_wasm_bg.wasm'), 'fixture wasm\n'); if (options.includeSecret) { writeFileSync(join(checkout, 'dist', 'secret.pem'), 'not-a-real-secret\n'); } From 2042a19ac53b0876ca1b36ba6b58320436bdaf9b Mon Sep 17 00:00:00 2001 From: ryjm Date: Wed, 26 Aug 2026 09:37:18 -0700 Subject: [PATCH 09/12] fix: derive canonical withdrawal limbs --- package-lock.json | 4 ++-- package.json | 2 +- src/withdrawal.ts | 52 +++++++++++++++++++++++++++++++++++------ test/withdrawal.test.ts | 8 ++++++- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0fe7c0..16a2829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nockbox/iris-sdk", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "dependencies": { "@noble/hashes": "2.3.0", diff --git a/package.json b/package.json index 41b5217..ffc6ef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nockbox/iris-sdk", - "version": "0.3.2", + "version": "0.3.3", "description": "TypeScript SDK for interacting with Iris wallet extension", "type": "module", "main": "./dist/index.js", diff --git a/src/withdrawal.ts b/src/withdrawal.ts index 751bd51..47a0ffc 100644 --- a/src/withdrawal.ts +++ b/src/withdrawal.ts @@ -1,7 +1,13 @@ import { keccak_256 } from '@noble/hashes/sha3.js'; import { base58 } from '@scure/base'; -import { initWasm, pkhSingle, spendConditionHash, spendConditionNewPkh } from './wasm.js'; -import type { Digest } from './wasm.js'; +import { + digestToProtobuf, + initWasm, + pkhSingle, + spendConditionHash, + spendConditionNewPkh, +} from './wasm.js'; +import type { Digest, PbCom1Belt } from './wasm.js'; export const WITHDRAWAL_WIRE_V1 = { protocol: 'WithdrawalWireV1', @@ -311,6 +317,36 @@ export function tip5LockRootLimbsFromBase58(lockRoot: string): Tip5LockRootLimbs return limbs; } +function tip5LockRootLimbsFromDigest(digest: Digest): Tip5LockRootLimbs { + const protobuf = digestToProtobuf(digest); + const belts: readonly (PbCom1Belt | null | undefined)[] = [ + protobuf.belt_1, + protobuf.belt_2, + protobuf.belt_3, + protobuf.belt_4, + protobuf.belt_5, + ]; + const beltValue = (belt: PbCom1Belt | null | undefined): bigint => { + const value = belt?.value; + if (typeof value !== 'string') { + throw new WithdrawalWireError( + 'invalid_lock_root', + 'Nockchain lock root did not decode to five Tip5 limbs' + ); + } + return BigInt(value); + }; + const limbs: Tip5LockRootLimbs = [ + beltValue(belts[0]), + beltValue(belts[1]), + beltValue(belts[2]), + beltValue(belts[3]), + beltValue(belts[4]), + ]; + validateLockRootLimbs(limbs); + return limbs; +} + export async function resolveWithdrawalDestinationV1( destination: WithdrawalDestinationV1 ): Promise { @@ -320,20 +356,22 @@ export async function resolveWithdrawalDestinationV1( ); let lockRoot = normalizedDestination; + let lockRootLimbs: Tip5LockRootLimbs; if (destination.kind === 'v1_pkh') { await initWasm(); const spendCondition = spendConditionNewPkh(pkhSingle(normalizedDestination as Digest)); - lockRoot = normalizeTip5Digest( - String(spendConditionHash(spendCondition)), - 'derived Nockchain lock root' - ); + const derivedLockRoot = spendConditionHash(spendCondition); + lockRoot = normalizeTip5Digest(String(derivedLockRoot), 'derived Nockchain lock root'); + lockRootLimbs = tip5LockRootLimbsFromDigest(derivedLockRoot); + } else { + lockRootLimbs = tip5LockRootLimbsFromBase58(lockRoot); } return { kind: destination.kind, normalizedDestination, lockRoot, - lockRootLimbs: tip5LockRootLimbsFromBase58(lockRoot), + lockRootLimbs, }; } diff --git a/test/withdrawal.test.ts b/test/withdrawal.test.ts index 4a98193..adbc6c7 100644 --- a/test/withdrawal.test.ts +++ b/test/withdrawal.test.ts @@ -221,7 +221,13 @@ test('v1 PKH destination resolves through its canonical spend lock root', async assert.equal(resolved.normalizedDestination, destination); assert.equal(base58.decode(resolved.lockRoot).length, 40); assert.equal(resolved.lockRootLimbs.length, 5); - assert.deepEqual(resolved.lockRootLimbs, tip5LockRootLimbsFromBase58(resolved.lockRoot)); + assert.deepEqual(resolved.lockRootLimbs, [ + 12_977_106_315_425_463_920n, + 3_763_978_537_871_087_579n, + 1_234_138_158_316_650_842n, + 4_616_755_078_196_705_664n, + 5_093_069_046_709_966_084n, + ]); }); test('bytes32 pseudo-root is rejected', () => { From c5ebc277ff89a508f4eede82f95e8d22765c8ba2 Mon Sep 17 00:00:00 2001 From: ryjm Date: Wed, 26 Aug 2026 22:43:03 -0700 Subject: [PATCH 10/12] style: format withdrawal package builder --- scripts/prepare-withdrawal-e2e-package.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/prepare-withdrawal-e2e-package.ts b/scripts/prepare-withdrawal-e2e-package.ts index d233e67..a78d68c 100644 --- a/scripts/prepare-withdrawal-e2e-package.ts +++ b/scripts/prepare-withdrawal-e2e-package.ts @@ -93,10 +93,7 @@ export async function prepareIrisE2ePackage( requireSupportedToolchain(nodeVersion, npmVersion); await run('npm', ['run', 'test:pack-readiness', '--silent', '--offline'], checkout); const wasmDestination = resolve(checkout, WASM_PATH); - const dependencyWasm = resolve( - checkout, - 'node_modules/@nockbox/iris-wasm/iris_wasm_bg.wasm' - ); + const dependencyWasm = resolve(checkout, 'node_modules/@nockbox/iris-wasm/iris_wasm_bg.wasm'); if (!(await pathExists(wasmDestination))) { await copyFile(dependencyWasm, wasmDestination); } From cf5f003d9b55a687cf7a9447913ae7e3d0534ec0 Mon Sep 17 00:00:00 2001 From: ryjm Date: Mon, 31 Aug 2026 15:39:09 -0700 Subject: [PATCH 11/12] fix(ci): require Node 22 for packaging tests --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f20a85a..0c20f9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies From 34f00b991e5050d877d9691c185883c409c5755f Mon Sep 17 00:00:00 2001 From: ryjm Date: Mon, 31 Aug 2026 15:39:09 -0700 Subject: [PATCH 12/12] chore: drop unused e2e driver plumbing --- package-lock.json | 3 --- package.json | 4 ---- tsconfig.e2e.json | 6 +----- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16a2829..627f742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,6 @@ "@nockbox/iris-wasm": "0.2.0", "@scure/base": "^2.0.0" }, - "bin": { - "iris-withdrawal-e2e": "dist/e2e/encode-withdrawal-e2e.js" - }, "devDependencies": { "@types/node": "^22.0.0", "esbuild": "0.21.5", diff --git a/package.json b/package.json index ffc6ef3..f817dcd 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,6 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", - "bin": { - "iris-withdrawal-e2e": "./dist/e2e/encode-withdrawal-e2e.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -40,7 +37,6 @@ "typecheck": "tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit", "test": "npm run build --silent && tsx --test test/*.test.ts", "test:pack-readiness": "npm run build --silent && tsx --test test/withdrawal.test.ts test/withdrawal-e2e-driver.test.ts", - "withdrawal:e2e": "npm run build --silent && node dist/e2e/encode-withdrawal-e2e.js", "package:withdrawal-e2e": "tsx scripts/prepare-withdrawal-e2e-package.ts", "prepublishOnly": "npm run build" }, diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json index 4f1f2d4..2302741 100644 --- a/tsconfig.e2e.json +++ b/tsconfig.e2e.json @@ -1,11 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": ".", - "outDir": "./dist/e2e", - "declaration": false, - "declarationMap": false, - "sourceMap": false + "rootDir": "." }, "include": ["scripts/*.ts"] }