diff --git a/package-lock.json b/package-lock.json index 40d65267..d5e47e94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1260,21 +1260,6 @@ "node": ">=14" } }, - "node_modules/@cardano-ogmios/client/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@cardano-ogmios/client/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -12874,7 +12859,6 @@ "@chainsafe/is-ip": "^2.0.1", "@chainsafe/netmask": "^2.0.0", "@multiformats/dns": "^1.0.3", - "abort-error": "^1.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" @@ -14211,9 +14195,9 @@ } }, "node_modules/@rollup/plugin-inject/node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16469,12 +16453,6 @@ "node": ">=6.5" } }, - "node_modules/abort-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/abort-error/-/abort-error-1.0.1.tgz", - "integrity": "sha512-fxqCblJiIPdSXIUrxI0PL+eJG49QdP9SQ70qtB65MVAoMr2rASlOyAbJFOylfB467F/f+5BCLJJq58RYi7mGfg==", - "license": "Apache-2.0 OR MIT" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -18637,6 +18615,7 @@ "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, "funding": [ { "type": "github", @@ -22823,6 +22802,7 @@ "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, "license": "ISC" }, "node_modules/html-escaper": { @@ -24657,6 +24637,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -26318,6 +26299,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -26782,6 +26764,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^2.1.4", @@ -26794,6 +26777,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver" @@ -30214,9 +30198,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", "funding": [ { "type": "opencollective", @@ -30655,9 +30639,9 @@ } }, "node_modules/publint/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "license": "MIT", "dependencies": { @@ -32488,6 +32472,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -32498,12 +32483,14 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -32514,6 +32501,7 @@ "version": "3.0.22", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "dev": true, "license": "CC0-1.0" }, "node_modules/speed-limiter": { @@ -32941,7 +32929,7 @@ "integrity": "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.9" + "style-to-object": "1.0.8" } }, "node_modules/style-to-object": { @@ -33111,21 +33099,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-check/node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -33760,11 +33733,10 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -33782,9 +33754,6 @@ }, "esbuild": { "optional": true - }, - "jest-util": { - "optional": true } } }, @@ -34398,9 +34367,9 @@ } }, "node_modules/typedoc/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "license": "MIT", "dependencies": { @@ -35012,6 +34981,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -35166,7 +35136,7 @@ "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.23" + "@swc/types": "^0.1.21" }, "engines": { "node": ">=10" @@ -35196,18 +35166,6 @@ } } }, - "node_modules/vite-plugin-top-level-await/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/vite-plugin-top-level-await/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -36139,6 +36097,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -36152,6 +36111,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -36197,6 +36157,16 @@ "node": ">=4.0" } }, + "node_modules/xstate": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz", + "integrity": "sha512-i9ZpNnm/XhCOMUxae1suT8PjYNTStZWbhmuKt4xeTPaYG5TS0Fz0i+Ka5yxoNPpaHW3VW6JIowrwFgSTZONxig==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -36384,7 +36354,7 @@ }, "packages/bitcoin": { "name": "@meshsdk/bitcoin", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", "bip174": "^3.0.0-rc.1", @@ -36410,7 +36380,7 @@ "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.23" + "@swc/types": "^0.1.21" }, "engines": { "node": ">=10" @@ -36440,18 +36410,6 @@ } } }, - "packages/bitcoin/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "packages/configs": { "name": "@meshsdk/configs", "version": "0.0.0", @@ -36671,7 +36629,7 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -36689,11 +36647,11 @@ }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core": "1.9.0-beta.69" + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core": "1.9.0-beta.71" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36704,15 +36662,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core-cst": "1.9.0-beta.69", - "@meshsdk/provider": "1.9.0-beta.69", - "@meshsdk/react": "1.9.0-beta.69", - "@meshsdk/transaction": "1.9.0-beta.69", - "@meshsdk/wallet": "1.9.0-beta.69" + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core-cst": "1.9.0-beta.71", + "@meshsdk/provider": "1.9.0-beta.71", + "@meshsdk/react": "1.9.0-beta.71", + "@meshsdk/transaction": "1.9.0-beta.71", + "@meshsdk/wallet": "1.9.0-beta.71" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36723,10 +36681,10 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/common": "1.9.0-beta.71", "@sidan-lab/whisky-js-browser": "^1.0.9", "@sidan-lab/whisky-js-nodejs": "^1.0.9", "@types/base32-encoding": "^1.0.2", @@ -36736,7 +36694,7 @@ }, "devDependencies": { "@meshsdk/configs": "*", - "@meshsdk/provider": "1.9.0-beta.69", + "@meshsdk/provider": "1.9.0-beta.71", "@types/json-bigint": "^1.0.4", "eslint": "^8.57.0", "ts-jest": "^29.1.4", @@ -36746,7 +36704,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "^0.45.5", @@ -36757,7 +36715,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.4", "@harmoniclabs/uplc": "1.2.4", - "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/common": "1.9.0-beta.71", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -36776,11 +36734,12 @@ }, "packages/mesh-hydra": { "name": "@meshsdk/hydra", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core-cst": "1.9.0-beta.69", - "axios": "^1.7.2" + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core-cst": "1.9.0-beta.71", + "axios": "^1.7.2", + "xstate": "^5.20.1" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36799,7 +36758,7 @@ "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.23" + "@swc/types": "^0.1.21" }, "engines": { "node": ">=10" @@ -36829,25 +36788,13 @@ } } }, - "packages/mesh-hydra/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "packages/mesh-provider": { "name": "@meshsdk/provider", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core-cst": "1.9.0-beta.69", + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core-cst": "1.9.0-beta.71", "@utxorpc/sdk": "^0.6.7", "@utxorpc/spec": "^0.16.0", "axios": "^1.7.2", @@ -36863,14 +36810,14 @@ }, "packages/mesh-react": { "name": "@meshsdk/react", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { "@fabianbormann/cardano-peer-connect": "^1.2.18", - "@meshsdk/bitcoin": "1.9.0-beta.69", - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/transaction": "1.9.0-beta.69", - "@meshsdk/wallet": "1.9.0-beta.69", + "@meshsdk/bitcoin": "1.9.0-beta.71", + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/transaction": "1.9.0-beta.71", + "@meshsdk/wallet": "1.9.0-beta.71", "@meshsdk/web3-sdk": "0.0.50", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -36908,10 +36855,10 @@ }, "packages/mesh-svelte": { "name": "@meshsdk/svelte", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/core": "1.9.0-beta.69", + "@meshsdk/core": "1.9.0-beta.71", "bits-ui": "1.0.0-next.65" }, "devDependencies": { @@ -36937,14 +36884,14 @@ }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "^0.45.5", "@cardano-sdk/input-selection": "^0.13.33", "@cardano-sdk/util": "^0.15.5", - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core-cst": "1.9.0-beta.69", + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core-cst": "1.9.0-beta.71", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -36957,12 +36904,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.69", - "@meshsdk/core-cst": "1.9.0-beta.69", - "@meshsdk/transaction": "1.9.0-beta.69", + "@meshsdk/common": "1.9.0-beta.71", + "@meshsdk/core-cst": "1.9.0-beta.71", + "@meshsdk/transaction": "1.9.0-beta.71", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -36975,7 +36922,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.69", + "version": "1.9.0-beta.71", "license": "Apache-2.0", "dependencies": { "@sidan-lab/cardano-bar": "^0.0.7", diff --git a/packages/mesh-hydra/package.json b/packages/mesh-hydra/package.json index 2a5a984a..daddffaf 100644 --- a/packages/mesh-hydra/package.json +++ b/packages/mesh-hydra/package.json @@ -24,18 +24,26 @@ "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "pack": "npm pack --pack-destination=./dist", - "test": "jest" + "test": "jest --testPathIgnorePatterns=tests/integration", + "test:integration": "jest tests/integration/hydra-machine-integration.test.ts --verbose", + "test:simple": "jest tests/integration/hydra-machine-simple-integration.test.ts --verbose", + "test:all-integration": "jest tests/integration/ --verbose" }, "dependencies": { "@meshsdk/common": "1.9.0-beta.71", "@meshsdk/core-cst": "1.9.0-beta.71", - "axios": "^1.7.2" + "axios": "^1.7.2", + "xstate": "^5.20.1" }, "devDependencies": { "@meshsdk/configs": "*", "@swc/core": "^1.10.7", + "@types/node-fetch": "^2.6.13", + "@types/ws": "^8.18.1", "eslint": "^8.57.0", + "node-fetch": "^3.3.2", "tsup": "^8.0.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "ws": "^8.18.3" } } diff --git a/packages/mesh-hydra/src/examples/example.ts b/packages/mesh-hydra/src/examples/example.ts new file mode 100644 index 00000000..a770e54a --- /dev/null +++ b/packages/mesh-hydra/src/examples/example.ts @@ -0,0 +1,26 @@ +import { HydraController } from "../hydra-controller"; + +const controller = new HydraController(); + +controller.on("*", (snapshot) => { + console.log("New state:", snapshot.value); +}); + +// Wait for specific compound state like Connected.Idle +controller.on("Connected.Idle", () => { + console.log("Hydra is now connected and idle, sending Init..."); + controller.init(); +}); + +// Connect to the Hydra node +controller.connect({ + baseURL: "http://localhost:4001", + address: "addr_test1vp5cxztpc6hep9ds7fjgmle3l225tk8ske3rmwr9adu0m6qchmx5z", + snapshot: true, + history: true, +}); + +controller.waitFor("Connected.Initializing.ReadyToCommit").then(() => { + console.log("Ready to commit, sending commit data..."); + controller.commit({}); +}); diff --git a/packages/mesh-hydra/src/examples/usage-example.ts b/packages/mesh-hydra/src/examples/usage-example.ts new file mode 100644 index 00000000..4d30c4f1 --- /dev/null +++ b/packages/mesh-hydra/src/examples/usage-example.ts @@ -0,0 +1,37 @@ +import { createActor } from "xstate"; +import { createHydraMachine } from "../state-management/hydra-machine-refactored"; +import { HTTPClient } from "../utils"; + +// Example 1: Basic usage (same as before) +const basicExample = () => { + const machine = createHydraMachine(); + const actor = createActor(machine); + + actor.start(); + actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + + return actor; +}; + +// Example 2: With custom configuration +const customExample = () => { + const machine = createHydraMachine({ + webSocketFactory: { + create: (url) => new WebSocket(url) + }, + httpClientFactory: { + create: (baseURL) => { + // Could add logging, interceptors, etc. + const { HTTPClient } = require("../utils"); + return new HTTPClient(baseURL); + } + } + }); + + const actor = createActor(machine); + actor.start(); + + return actor; +}; + +export { basicExample, customExample }; diff --git a/packages/mesh-hydra/src/hydra-controller.ts b/packages/mesh-hydra/src/hydra-controller.ts new file mode 100644 index 00000000..5157ef13 --- /dev/null +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -0,0 +1,269 @@ +import { ActorRefFrom, createActor, StateValue, Subscription } from "xstate"; +import { + createHydraMachine, + HydraMachineConfig, + Transaction, +} from "./state-management/hydra-machine"; +import { Emitter } from "./utils/emitter"; +import { HTTPClient } from "./utils"; + +type ConnectOptions = { + baseURL: string; + address?: string; + snapshot?: boolean; + history?: boolean; +}; + +type HydraStateName = + | "*" + | "Disconnected" + | "Connected" + | "Connected.Handshake" + | "Connected.NoHead" + | "Connected.Initializing" + | "Connected.Initializing.Waiting" + | "Connected.Initializing.Depositing" + | "Connected.Initializing.Depositing.ReadyToCommit" + | "Connected.Initializing.Depositing.RequestDraft" + | "Connected.Initializing.Depositing.AwaitSignature" + | "Connected.Initializing.Depositing.SubmittingDeposit" + | "Connected.Initializing.Depositing.AwaitingCommitConfirmation" + | "Connected.Open" + | "Connected.Open.Active" + | "Connected.Open.Depositing" + | "Connected.Open.Depositing.ReadyToCommit" + | "Connected.Open.Depositing.RequestDraft" + | "Connected.Open.Depositing.AwaitSignature" + | "Connected.Open.Depositing.SubmittingDeposit" + | "Connected.Open.Depositing.AwaitingCommitConfirmation" + | "Connected.Closed" + | "Connected.FanoutPossible" + | "Connected.Final"; + +type Snapshot = ReturnType< + ActorRefFrom>["getSnapshot"] +>; + +type Events = { + "*": (snapshot: Snapshot) => void; +} & { + [K in HydraStateName]: (snapshot: Snapshot) => void; +}; + +export class HydraController { + private actor: ActorRefFrom>; + private emitter = new Emitter(); + private _currentSnapshot?: Snapshot; + private httpClient?: HTTPClient; + private subscription?: Subscription; + + constructor(config?: HydraMachineConfig) { + this.actor = createActor(createHydraMachine(config)); + this.subscription = this.actor.subscribe({ + next: (snapshot) => this.handleState(snapshot), + error: (err) => console.error("Hydra error:", err), + }); + this.actor.start(); + } + + /** Connect to the Hydra head */ + connect(options: ConnectOptions) { + this.actor.send({ type: "Connect", ...options }); + this.httpClient = new HTTPClient(options.baseURL); + } + + /** Protocol commands */ + init() { + this.validateStateForOperation("Init", [ + "Connected.NoHead", + "Connected.Final", + ]); + this.actor.send({ type: "Init" }); + } + + commit(data: Record = {}) { + this.validateStateForOperation("Commit", [ + "Connected.Initializing.Waiting", + "Connected.Initializing.Depositing.ReadyToCommit", + "Connected.Open.Active", + ]); + this.actor.send({ type: "Commit", data }); + } + + newTx(tx: Transaction) { + this.validateStateForOperation("NewTx", ["Connected.Open"]); + this.actor.send({ type: "NewTx", tx }); + } + + recover(txHash: string) { + this.validateStateForOperation("Recover", ["Connected.Open"]); + this.actor.send({ type: "Recover", txHash }); + } + + decommit(tx: Transaction) { + this.validateStateForOperation("Decommit", ["Connected.Open"]); + this.actor.send({ type: "Decommit", tx }); + } + + close() { + this.validateStateForOperation("Close", ["Connected.Open"]); + this.actor.send({ type: "Close" }); + } + + contest() { + this.validateStateForOperation("Contest", ["Connected.Closed"]); + this.actor.send({ type: "Contest" }); + } + + fanout() { + this.validateStateForOperation("Fanout", ["Connected.FanoutPossible"]); + this.actor.send({ type: "Fanout" }); + } + + sideLoadSnapshot(snapshot: unknown) { + this.validateStateForOperation("SideLoadSnapshot", ["Connected.Open"]); + this.actor.send({ type: "SideLoadSnapshot", snapshot }); + } + + /** HTTP API methods */ + async getHeadState() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/head"); + } + + async getPendingDeposits() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/commits"); + } + + async recoverDeposit(txId: string) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.delete(`/commits/${txId}`); + } + + async getLastSeenSnapshot() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/snapshot/last-seen"); + } + + async getConfirmedUTxO() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/snapshot/utxo"); + } + + async getConfirmedSnapshot() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/snapshot"); + } + + async postSideLoadSnapshot(snapshot: unknown) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.post("/snapshot", snapshot); + } + + async postDecommit(tx: unknown) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.post("/decommit", tx); + } + + async getProtocolParameters() { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.get("/protocol-parameters"); + } + + async submitCardanoTransaction(tx: Transaction) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.post("/cardano-transaction", tx); + } + + async submitL2Transaction(tx: Transaction) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.post("/transaction", tx); + } + + private handleState(snapshot: Snapshot) { + if ( + JSON.stringify(snapshot.value) === + JSON.stringify(this._currentSnapshot?.value) + ) + return; + this._currentSnapshot = snapshot; + this.emitter.emit("*", snapshot); + this.emitter.emit(_flattenState(snapshot.value), snapshot); + } + + on(state: HydraStateName, fn: (s: Snapshot) => void) { + return this.emitter.on(state, fn); + } + + once(state: HydraStateName, fn: (s: Snapshot) => void) { + return this.emitter.once(state, fn); + } + + off(state: HydraStateName, fn: (s: Snapshot) => void) { + return this.emitter.off(state, fn); + } + + waitFor(state: HydraStateName, timeout?: number): Promise { + return new Promise((resolve, reject) => { + const onMatch = () => { + clearTimeout(timer); + this.off(state, onMatch); + resolve(); + }; + + const timer = timeout + ? setTimeout(() => { + this.off(state, onMatch); + reject(new Error(`Timeout waiting for state "${state}"`)); + }, timeout) + : undefined; + + this.once(state, onMatch); + }); + } + + stop() { + this.subscription?.unsubscribe(); + this.actor.stop(); + this.emitter.clear(); + this._currentSnapshot = undefined; + this.httpClient = undefined; + this.subscription = undefined; + } + + /** + * Validates if the current state allows the specified operation + */ + private validateStateForOperation( + operation: string, + allowedStates: string[], + ) { + const currentState = _flattenState(this.state || ""); + const isAllowed = allowedStates.some( + (state) => currentState.includes(state) || currentState === state, + ); + + if (!isAllowed) { + throw new Error( + `Operation '${operation}' is not allowed in current state: ${currentState}. ` + + `Allowed states: ${allowedStates.join(", ")}`, + ); + } + } + + get state() { + return this._currentSnapshot?.value; + } + + get context() { + return this._currentSnapshot?.context; + } +} + +function _flattenState(value: StateValue): HydraStateName { + if (typeof value === "string") return value as HydraStateName; + return Object.entries(value) + .map(([k, v]) => (v ? `${k}.${_flattenState(v)}` : k)) + .join(".") as HydraStateName; +} diff --git a/packages/mesh-hydra/src/mocks/MockHTTPClient.ts b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts new file mode 100644 index 00000000..4427eafe --- /dev/null +++ b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts @@ -0,0 +1,47 @@ +import { HTTPClient } from "../utils"; + +export class MockHttpClient extends HTTPClient { + public static instances: MockHttpClient[] = []; + public static nextPostErrors: Error[] = []; + public static postCalls: Array<{ endpoint: string; payload: unknown }> = []; + public static nextPostResponses: unknown[] = []; + + constructor(baseURL: string) { + super(baseURL); + MockHttpClient.instances.push(this); + } + + async post(endpoint: string, payload: unknown) { + MockHttpClient.postCalls.push({ endpoint, payload }); + if (MockHttpClient.nextPostErrors.length > 0) { + throw MockHttpClient.nextPostErrors.shift(); + } + if (MockHttpClient.nextPostResponses.length > 0) { + return MockHttpClient.nextPostResponses.shift(); + } + // Default response for /commit endpoint - return a draft transaction + if (endpoint === "/commit") { + return { + type: "TxBabbage", + description: "Draft commit tx", + cborHex: "84a4...", + }; + } + return { status: 200, data: "ok" }; + } + + async get(endpoint: string) { + return { status: 200, data: {} }; + } + + async delete(endpoint: string) { + return { status: 200, data: "ok" }; + } + + public static reset() { + MockHttpClient.instances = []; + MockHttpClient.nextPostErrors = []; + MockHttpClient.postCalls = []; + MockHttpClient.nextPostResponses = []; + } +} diff --git a/packages/mesh-hydra/src/mocks/MockWebSocket.ts b/packages/mesh-hydra/src/mocks/MockWebSocket.ts new file mode 100644 index 00000000..ea85a7e8 --- /dev/null +++ b/packages/mesh-hydra/src/mocks/MockWebSocket.ts @@ -0,0 +1,56 @@ +type WebSocketEventType = 'open' | 'message' | 'close' | 'error'; + +export class MockWebSocket { + public readyState: number = WebSocket.CONNECTING; + public onopen: ((event: Event) => void) | null = null; + public onmessage: ((event: MessageEvent) => void) | null = null; + public onclose: ((event: CloseEvent) => void) | null = null; + public onerror: ((event: Event) => void) | null = null; + + public sentMessages: string[] = []; + public eventLog: { type: WebSocketEventType; data?: unknown }[] = []; + + + constructor(public url: string) { + // Simulate async open + setImmediate(() => { + this.readyState = WebSocket.OPEN; + this.onopen?.(new Event('open')); + }); + } + + send(data: string) { + if (this.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open"); + } + this.sentMessages.push(data); + } + + close(code = 1000, reason = "") { + this.readyState = WebSocket.CLOSED; + this.onclose?.(new CloseEvent('close', { wasClean: false, code, reason })); + } + + // Test helper to simulate an incoming JSON message + mockReceive(obj: unknown) { + const data = typeof obj === "string" ? obj : JSON.stringify(obj); + this.onmessage?.(new MessageEvent('message', { data })); + } + + mockError(errorData?: unknown) { + const err = new Event('error'); + this.logEvent('error', errorData ?? err); + this.onerror?.(err); + } + + mockClose(code = 1006, reason = 'Unexpected closure') { + if (this.readyState === WebSocket.CLOSED) return; + this.readyState = WebSocket.CLOSED; + this.logEvent('close', { code, reason }); + this.onclose?.(new CloseEvent('close', { wasClean: false, code, reason })); + } + + private logEvent(type: WebSocketEventType, data?: unknown) { + this.eventLog.push({ type, data }); + } +} diff --git a/packages/mesh-hydra/src/mocks/index.ts b/packages/mesh-hydra/src/mocks/index.ts new file mode 100644 index 00000000..33dc1d90 --- /dev/null +++ b/packages/mesh-hydra/src/mocks/index.ts @@ -0,0 +1,2 @@ +export * from "./MockWebSocket"; +export * from "./MockHTTPClient"; diff --git a/packages/mesh-hydra/src/state-management/hydra-machine.test.ts b/packages/mesh-hydra/src/state-management/hydra-machine.test.ts new file mode 100644 index 00000000..f79240a6 --- /dev/null +++ b/packages/mesh-hydra/src/state-management/hydra-machine.test.ts @@ -0,0 +1,433 @@ +import { createActor } from "xstate"; +import { + createHydraMachine, + HTTPClientFactory, + WebSocketFactory, +} from "./hydra-machine"; +import { HTTPClient } from "../utils"; +import { MockHttpClient } from "../mocks/MockHTTPClient"; +import { MockWebSocket } from "../mocks/MockWebSocket"; + +// Create a mock HTTPClientFactory +class MockHTTPClientFactory implements HTTPClientFactory { + create(baseURL: string): HTTPClient { + return new MockHttpClient(baseURL) as HTTPClient; + } +} + +// Create a mock WebSocketFactory +class MockWebSocketFactory implements WebSocketFactory { + create(url: string): WebSocket { + return new MockWebSocket(url) as unknown as WebSocket; + } +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function stateToString(value: any): string { + if (typeof value === "string") return value; + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return ""; + const k = keys[0] as keyof typeof obj; + const v = obj[k]; + return `${k}.${stateToString(v)}`; +} + +describe("hydra-machine state transitions", () => { + let actor: any; + let ws: MockWebSocket; + + beforeEach(() => { + MockHttpClient.reset(); + const machine = createHydraMachine({ + httpClientFactory: new MockHTTPClientFactory(), + webSocketFactory: new MockWebSocketFactory(), + }); + actor = createActor(machine); + actor.start(); + }); + + afterEach(() => { + actor.stop(); + }); + + describe("Connection flow", () => { + test("Disconnected -> Connected.Handshake -> NoHead", async () => { + expect(stateToString(actor.getSnapshot().value)).toBe("Disconnected"); + + // Connect triggers server invoke; TestWebSocket opens and emits Ready + actor.send({ + type: "Connect", + baseURL: "http://localhost:4001", + history: true, + snapshot: false, + }); + await flush(); + + // Should be in Handshake waiting for Greetings + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Handshake", + ); + + // Get WebSocket instance + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; + + // Send Greetings with Idle status + ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); + + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.NoHead"); + expect(actor.getSnapshot().context.client).toBeDefined(); + }); + + test("Handshake transitions to correct state based on Greetings", async () => { + const testCases = [ + { headStatus: "Idle", expectedState: "Connected.NoHead" }, + { + headStatus: "Initializing", + expectedState: "Connected.Initializing.Waiting", + }, + { headStatus: "Open", expectedState: "Connected.Open.Active" }, + { headStatus: "Closed", expectedState: "Connected.Closed" }, + { + headStatus: "FanoutPossible", + expectedState: "Connected.FanoutPossible", + }, + { headStatus: "Final", expectedState: "Connected.Final" }, + ]; + + for (const { headStatus, expectedState } of testCases) { + const machine = createHydraMachine({ + httpClientFactory: new MockHTTPClientFactory(), + webSocketFactory: new MockWebSocketFactory(), + }); + const testActor = createActor(machine); + testActor.start(); + + testActor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + + const testWs = testActor.getSnapshot().context + .connection as unknown as MockWebSocket; + testWs.mockReceive({ tag: "Greetings", headStatus }); + + expect(stateToString(testActor.getSnapshot().value)).toBe( + expectedState, + ); + testActor.stop(); + } + }); + }); + + describe("Initializing state", () => { + beforeEach(async () => { + actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; + ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); + }); + + test("Init -> Initializing -> Commit flow -> Open", async () => { + // Start head initialization + actor.send({ type: "Init" }); + + // Server indicates head is initializing + ws.mockReceive({ tag: "HeadIsInitializing" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Waiting", + ); + + // Start commit process + const request = { utxos: ["utxo1", "utxo2"] }; + actor.send({ type: "Commit", data: request }); + + // Should be in RequestDraft state + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.RequestDraft", + ); + + // Wait for HTTP call to complete + await flush(); + + // HTTP was called + expect(MockHttpClient.postCalls[0]).toEqual({ + endpoint: "/commit", + payload: request, + }); + + // Should move to AwaitSignature with draft tx + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.AwaitSignature", + ); + expect(actor.getSnapshot().context.draftTx).toBeDefined(); + + // Submit signed deposit + const signedTx = { + type: "TxBabbage", + description: "Signed tx", + cborHex: "signed...", + }; + actor.send({ type: "SubmitSignedDeposit", tx: signedTx }); + + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.SubmittingDeposit", + ); + + // Wait for submission + await flush(); + + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.AwaitingCommitConfirmation", + ); + + // Server confirms commit (legacy flow for initial commits) + ws.mockReceive({ tag: "Committed" }); + + // Should complete depositing and return to waiting + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Waiting", + ); + + // Head opens + ws.mockReceive({ tag: "HeadIsOpen" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Active", + ); + }); + + test("Abort during Initializing", async () => { + ws.mockReceive({ tag: "HeadIsInitializing" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Waiting", + ); + + actor.send({ type: "Abort" }); + ws.mockReceive({ tag: "HeadIsAborted" }); + + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Final"); + }); + + test("Commit error handling and retry", async () => { + ws.mockReceive({ tag: "HeadIsInitializing" }); + + // First commit attempt will fail - set up error before sending + MockHttpClient.nextPostErrors.push(new Error("Network error")); + actor.send({ type: "Commit", data: { attempt: 1 } }); + + // Should be in RequestDraft state briefly + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.RequestDraft", + ); + + // Wait for error to process + await flush(); + + // Should return to ReadyToCommit within Depositing + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.ReadyToCommit", + ); + expect(actor.getSnapshot().context.error).toBeDefined(); + + // Retry successfully + actor.send({ type: "Commit", data: { attempt: 2 } }); + + // Should be in RequestDraft again + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.RequestDraft", + ); + + await flush(); + + // Should move to AwaitSignature after successful draft + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Initializing.Depositing.AwaitSignature", + ); + }); + }); + + describe("Open state", () => { + beforeEach(async () => { + actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; + ws.mockReceive({ tag: "Greetings", headStatus: "Open" }); + }); + + test("NewTx command", async () => { + const tx = "84a4..."; + actor.send({ type: "NewTx", tx }); + + const sentMessages = ws.sentMessages; + const lastMessage = JSON.parse( + sentMessages[sentMessages.length - 1] as string, + ); + expect(lastMessage).toEqual({ tag: "NewTx", transaction: tx }); + }); + + test("Incremental commit flow", async () => { + const request = { newUtxo: "utxo3" }; + actor.send({ type: "Commit", data: request }); + + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Depositing.RequestDraft", + ); + + await flush(); + + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Depositing.AwaitSignature", + ); + + // External submission + actor.send({ type: "DepositSubmittedExternally" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Depositing.AwaitingCommitConfirmation", + ); + + // Server confirms incremental commit + ws.mockReceive({ tag: "CommitFinalized" }); + + // Should return to Active + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Active", + ); + }); + + test("Close -> Closed -> Contest flow", async () => { + actor.send({ type: "Close" }); + ws.mockReceive({ tag: "HeadIsClosed" }); + + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Closed"); + + // Contest the closure + actor.send({ type: "Contest" }); + + // Contest updates the closed state, doesn't transition + ws.mockReceive({ tag: "HeadIsContested" }); + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Closed"); + + // Ready to fanout + ws.mockReceive({ tag: "ReadyToFanout" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.FanoutPossible", + ); + + // Fanout + actor.send({ type: "Fanout" }); + ws.mockReceive({ tag: "HeadIsFinalized" }); + + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Final"); + }); + + test("Decommit and Recover commands", async () => { + const decommitTx = "decommit_tx"; + actor.send({ type: "Decommit", tx: decommitTx }); + + let lastMessage = JSON.parse( + ws.sentMessages[ws.sentMessages.length - 1] as string, + ); + expect(lastMessage).toEqual({ tag: "Decommit", decommitTx }); + + const txHash = "abc123"; + actor.send({ type: "Recover", txHash }); + + lastMessage = JSON.parse( + ws.sentMessages[ws.sentMessages.length - 1] as string, + ); + expect(lastMessage).toEqual({ tag: "Recover", recoverTxId: txHash }); + }); + }); + + describe("Error handling", () => { + beforeEach(async () => { + actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; + ws.mockReceive({ tag: "Greetings", headStatus: "Open" }); + }); + + test("InvalidInput error", () => { + ws.mockReceive({ + tag: "InvalidInput", + reason: "Invalid transaction format", + input: "bad_tx", + }); + + const error = actor.getSnapshot().context.error; + expect(error).toBeDefined(); + expect(error.kind).toBe("InvalidInput"); + }); + + test("CommandFailed error", () => { + ws.mockReceive({ + tag: "CommandFailed", + clientInput: { tag: "Close" }, + }); + + const error = actor.getSnapshot().context.error; + expect(error).toBeDefined(); + expect(error.kind).toBe("CommandFailed"); + }); + + test("Network events don't change state", () => { + const initialState = stateToString(actor.getSnapshot().value); + + ws.mockReceive({ tag: "NetworkConnected" }); + ws.mockReceive({ tag: "NetworkDisconnected" }); + ws.mockReceive({ tag: "PeerConnected", peer: "peer1" }); + ws.mockReceive({ tag: "PeerDisconnected", peer: "peer1" }); + + expect(stateToString(actor.getSnapshot().value)).toBe(initialState); + }); + }); + + describe("Complete lifecycle", () => { + test("Full head lifecycle: Init -> Open -> Close -> Fanout", async () => { + // Connect + actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; + + // Start from idle + ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.NoHead"); + + // Initialize + actor.send({ type: "Init" }); + ws.mockReceive({ tag: "HeadIsInitializing" }); + + // Wait for others to commit + ws.mockReceive({ tag: "Committed", party: "alice" }); + ws.mockReceive({ tag: "Committed", party: "bob" }); + + // Head opens + ws.mockReceive({ tag: "HeadIsOpen" }); + expect(stateToString(actor.getSnapshot().value)).toBe( + "Connected.Open.Active", + ); + + // Submit transactions + actor.send({ type: "NewTx", tx: "tx1" }); + ws.mockReceive({ tag: "TxValid", transaction: "tx1" }); + ws.mockReceive({ tag: "SnapshotConfirmed", snapshot: { number: 1 } }); + + // Close head + actor.send({ type: "Close" }); + ws.mockReceive({ tag: "HeadIsClosed" }); + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Closed"); + + // Fanout + ws.mockReceive({ tag: "ReadyToFanout" }); + actor.send({ type: "Fanout" }); + ws.mockReceive({ tag: "HeadIsFinalized" }); + + expect(stateToString(actor.getSnapshot().value)).toBe("Connected.Final"); + + // Can start new head from Final state + actor.send({ type: "Init" }); + expect(ws.sentMessages.some((m) => JSON.parse(m).tag === "Init")).toBe( + true, + ); + }); + }); +}); diff --git a/packages/mesh-hydra/src/state-management/hydra-machine.ts b/packages/mesh-hydra/src/state-management/hydra-machine.ts new file mode 100644 index 00000000..9ad75e6c --- /dev/null +++ b/packages/mesh-hydra/src/state-management/hydra-machine.ts @@ -0,0 +1,1042 @@ +import { + AnyEventObject, + assertEvent, + assign, + fromCallback, + fromPromise, + sendTo, + setup, +} from "xstate"; +import { HTTPClient } from "../utils"; + +/** ===== Strict types for Cardano transactions accepted by hydra-node ===== */ + +/** Hex-encoded CBOR string (hydra-node accepts this form). */ +export type CborHex = string; + +/** TextEnvelope wrapper ({"type", "description", "cborHex"}) */ +export type TxTextEnvelope = { + type: string; + description: string; + cborHex: CborHex; +}; + +/** JSON format of Cardano transaction */ +export type TxJSON = Record; + +/** Transaction payload accepted by hydra-node via WS/HTTP. */ +export type Transaction = TxTextEnvelope | CborHex | TxJSON; + +/** ===== Minimal shapes for server outputs we care about (tags per AsyncAPI) ===== */ + +type TimedMeta = { + seq?: number; + timestamp?: string; +}; + +type HeadStatus = + | "Idle" + | "Initializing" + | "Open" + | "Closed" + | "FanoutPossible" + | "Final"; + +type MsgGreetings = { + tag: "Greetings"; + headStatus: HeadStatus; +}; + +type MsgHeadState = + | { tag: "HeadIsInitializing" } + | { tag: "HeadIsOpen" } + | { tag: "HeadIsClosed" } + | { tag: "HeadIsContested" } + | { tag: "ReadyToFanout" } + | { tag: "HeadIsAborted" } + | { tag: "HeadIsFinalized" }; + +type MsgCommitted = { tag: "Committed" }; + +type MsgCommitRecorded = { tag: "CommitRecorded" }; +type MsgCommitApproved = { tag: "CommitApproved" }; +type MsgCommitFinalized = { tag: "CommitFinalized" }; +type MsgCommitRecovered = { tag: "CommitRecovered" }; + +// Network events +type MsgNetworkConnected = { tag: "NetworkConnected" }; +type MsgNetworkDisconnected = { tag: "NetworkDisconnected" }; +type MsgPeerConnected = { tag: "PeerConnected"; peer: string }; +type MsgPeerDisconnected = { tag: "PeerDisconnected"; peer: string }; +type MsgNetworkVersionMismatch = { tag: "NetworkVersionMismatch" }; +type MsgNetworkClusterIDMismatch = { tag: "NetworkClusterIDMismatch" }; + +// Transaction events +type MsgTxValid = { tag: "TxValid"; transaction: Transaction }; +type MsgSnapshotConfirmed = { tag: "SnapshotConfirmed" }; + +// Decommit events +type MsgDecommitInvalid = { tag: "DecommitInvalid" }; +type MsgDecommitRequested = { tag: "DecommitRequested" }; +type MsgDecommitApproved = { tag: "DecommitApproved" }; +type MsgDecommitFinalized = { tag: "DecommitFinalized" }; + +// Deposit events +type MsgDepositRecorded = { + tag: "DepositRecorded"; + depositTxId: string; + deposited: unknown; // UTxO + deadline: string; +}; +type MsgDepositActivated = { + tag: "DepositActivated"; + depositTxId: string; + deadline: string; +}; +type MsgDepositExpired = { + tag: "DepositExpired"; + depositTxId: string; + deadline: string; +}; + +// Other events +type MsgIgnoredHeadInitializing = { tag: "IgnoredHeadInitializing" }; +type MsgSnapshotSideLoaded = { tag: "SnapshotSideLoaded" }; +type MsgEventLogRotated = { tag: "EventLogRotated" }; + +type MsgInvalidInput = { tag: "InvalidInput"; reason: string; input: string }; +type MsgCommandFailed = { tag: "CommandFailed"; clientInput: unknown }; +type MsgTxInvalid = { + tag: "TxInvalid"; + validationError?: { reason?: string } | string; +}; +type MsgPostTxOnChainFailed = { + tag: "PostTxOnChainFailed"; + postTxError: unknown; +}; + +type HydraServerOutput = TimedMeta & + ( + | MsgGreetings + | MsgHeadState + | MsgCommitted + | MsgCommitRecorded + | MsgCommitApproved + | MsgCommitFinalized + | MsgCommitRecovered + | MsgNetworkConnected + | MsgNetworkDisconnected + | MsgPeerConnected + | MsgPeerDisconnected + | MsgNetworkVersionMismatch + | MsgNetworkClusterIDMismatch + | MsgTxValid + | MsgTxInvalid + | MsgSnapshotConfirmed + | MsgDecommitInvalid + | MsgDecommitRequested + | MsgDecommitApproved + | MsgDecommitFinalized + | MsgDepositRecorded + | MsgDepositActivated + | MsgDepositExpired + | MsgIgnoredHeadInitializing + | MsgSnapshotSideLoaded + | MsgEventLogRotated + | MsgInvalidInput + | MsgCommandFailed + | MsgPostTxOnChainFailed + | { tag: string; [k: string]: unknown } + ); + +type PostTxErrorDetail = + | { tag: "ScriptFailedInWallet"; redeemerPtr: string; failureReason: string } + | { tag: "InternalWalletError"; reason: string } + | { tag: "NotEnoughFuel" } + | { tag: "NoFuelUTXOFound" } + | { tag: "CannotFindOwnInitial" } + | { tag: "UnsupportedLegacyOutput" } + | { tag: "NoSeedInput" } + | { tag: "InvalidStateToPost"; reason: string } + | { tag: "FailedToPostTx"; reason: string } + | { tag: "CommittedTooMuchADAForMainnet"; committed: number; maximum: number } + | { tag: "FailedToDraftTxNotInitializing" } + | { tag: "InvalidSeed" } + | { tag: "InvalidHeadId" } + | { tag: "FailedToConstructAbortTx" } + | { tag: "FailedToConstructCloseTx" } + | { tag: "FailedToConstructContestTx" } + | { tag: "FailedToConstructCollectTx" } + | { tag: "FailedToConstructDepositTx" } + | { tag: "FailedToConstructRecoverTx" } + | { tag: "FailedToConstructIncrementTx" } + | { tag: "FailedToConstructDecrementTx" } + | { tag: "FailedToConstructFanoutTx" } + | { tag: "DepositTooLow"; deposit: number; minDeposit: number } + | { tag: "AmountTooLow"; lovelace: number }; + +type DecommitInvalidReason = + | { tag: "DecommitTxInvalid"; validationError: { reason?: string } } + | { tag: "DecommitAlreadyInFlight" }; + +type HydraError = + | { kind: "InvalidInput"; message: string; source: MsgInvalidInput } + | { kind: "CommandFailed"; message: string; source: MsgCommandFailed } + | { kind: "TxInvalid"; message?: string; source: MsgTxInvalid } + | { + kind: "PostTxOnChainFailed"; + message?: string; + source: MsgPostTxOnChainFailed; + detail?: PostTxErrorDetail; + } + | { + kind: "DecommitInvalid"; + message?: string; + reason?: DecommitInvalidReason; + }; + +// Define interfaces for dependencies to enable dependency injection +export interface WebSocketFactory { + create(url: string): WebSocket; +} + +export interface HTTPClientFactory { + create(baseURL: string): HTTPClient; +} + +// Default implementations +export class DefaultWebSocketFactory implements WebSocketFactory { + create(url: string): WebSocket { + return new WebSocket(url); + } +} + +export class DefaultHTTPClientFactory implements HTTPClientFactory { + create(baseURL: string): HTTPClient { + return new HTTPClient(baseURL); + } +} + +// Configuration interface for the machine +export interface HydraMachineConfig { + webSocketFactory?: WebSocketFactory; + httpClientFactory?: HTTPClientFactory; +} + +export function createHydraMachine(config: HydraMachineConfig = {}) { + const { + webSocketFactory = new DefaultWebSocketFactory(), + httpClientFactory = new DefaultHTTPClientFactory(), + } = config; + + return setup({ + actions: { + /** === WebSocket commands === */ + newTx: sendTo("server", ({ event }) => { + assertEvent(event, "NewTx"); + return { type: "Send", data: { tag: "NewTx", transaction: event.tx } }; + }), + recoverUTxO: sendTo("server", ({ event }) => { + assertEvent(event, "Recover"); + return { + type: "Send", + data: { tag: "Recover", recoverTxId: event.txHash }, + }; + }), + decommitUTxO: sendTo("server", ({ event }) => { + assertEvent(event, "Decommit"); + return { + type: "Send", + data: { tag: "Decommit", decommitTx: event.tx }, + }; + }), + initHead: sendTo("server", { type: "Send", data: { tag: "Init" } }), + abortHead: sendTo("server", { type: "Send", data: { tag: "Abort" } }), + closeHead: sendTo("server", { type: "Send", data: { tag: "Close" } }), + contestHead: sendTo("server", { + type: "Send", + data: { tag: "Contest" }, + }), + fanoutHead: sendTo("server", { type: "Send", data: { tag: "Fanout" } }), + sideLoadSnapshot: sendTo("server", ({ event }) => { + assertEvent(event, "SideLoadSnapshot"); + return { + type: "Send", + data: { tag: "SideLoadSnapshot", snapshot: event.snapshot }, + }; + }), + + /** === Connection / context === */ + closeConnection: assign(({ context }) => { + if (context.connection?.readyState === 1) { + // WebSocket.OPEN + context.connection.close(1000, "Client disconnected"); + } + return { + baseURL: "", + headURL: "", + connection: undefined, + client: undefined, + error: undefined, + request: undefined, + draftTx: undefined, + signedDepositTx: undefined, + }; + }), + setURL: assign(({ event }) => { + assertEvent(event, "Connect"); + const url = event.baseURL.replace(/^http/, "ws"); // http->ws, https->wss + const history = `history=${event.history ? "yes" : "no"}`; + const snapshot = `snapshot-utxo=${event.snapshot ? "yes" : "no"}`; + const address = event.address + ? `&address=${encodeURIComponent(event.address)}` + : ""; + return { + baseURL: event.baseURL, + headURL: `${url}/?${history}&${snapshot}${address}`, + }; + }), + setConnection: assign(({ event }) => { + assertEvent(event, "Ready"); + return { connection: event.connection }; + }), + createClient: assign(({ context }) => ({ + client: httpClientFactory.create(context.baseURL), + })), + setError: assign(({ event }) => { + const anyEvent = event as any; + const data = "data" in anyEvent ? anyEvent.data : anyEvent.error; + return { error: data ?? anyEvent }; + }), + clearError: assign(() => ({ error: undefined })), + setRequest: assign(({ event }) => { + assertEvent(event, "Commit"); + return { request: event.data }; + }), + clearRequest: assign(() => ({ request: undefined })), + clearDraftTx: assign(() => ({ + draftTx: undefined, + signedDepositTx: undefined, + })), + + /** === Error capture from server messages === */ + captureServerError: assign(({ event }) => { + assertEvent(event, "Message"); + const msg = event.data as HydraServerOutput; + if (msg.tag === "InvalidInput") { + const typedMsg = msg as MsgInvalidInput; + const err: HydraError = { + kind: "InvalidInput", + message: typedMsg.reason, + source: typedMsg, + }; + return { error: err }; + } + if (msg.tag === "CommandFailed") { + const typedMsg = msg as MsgCommandFailed; + const err: HydraError = { + kind: "CommandFailed", + message: "Command failed", + source: typedMsg, + }; + return { error: err }; + } + if (msg.tag === "TxInvalid") { + const typedMsg = msg as MsgTxInvalid; + const reason = + typeof typedMsg.validationError === "string" + ? typedMsg.validationError + : typedMsg.validationError?.reason; + const err: HydraError = { + kind: "TxInvalid", + message: reason, + source: typedMsg, + }; + return { error: err }; + } + if (msg.tag === "PostTxOnChainFailed") { + const typedMsg = msg as MsgPostTxOnChainFailed; + const err: HydraError = { + kind: "PostTxOnChainFailed", + message: "PostTx failed", + source: typedMsg, + detail: typedMsg.postTxError as PostTxErrorDetail, + }; + return { error: err }; + } + if (msg.tag === "DecommitInvalid") { + const typedMsg = msg as MsgDecommitInvalid; + const err: HydraError = { + kind: "DecommitInvalid", + message: "Decommit invalid", + reason: (typedMsg as any) + .decommitInvalidReason as DecommitInvalidReason, + }; + return { error: err }; + } + return {}; + }), + }, + + guards: { + /** Head status guards */ + isGreetings: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "Greetings"; + }, + isIdle: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + d.tag === "Greetings" && (d as MsgGreetings).headStatus === "Idle" + ); + }, + isInitializing: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + (d.tag === "Greetings" && + (d as MsgGreetings).headStatus === "Initializing") || + d.tag === "HeadIsInitializing" + ); + }, + isAborted: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "HeadIsAborted"; + }, + isCommitted: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "Committed"; + }, + isCommitRecorded: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "CommitRecorded"; + }, + isCommitFinalized: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "CommitFinalized"; + }, + isCommitRecovered: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "CommitRecovered"; + }, + isCommitApproved: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "CommitApproved"; + }, + isOpen: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + (d.tag === "Greetings" && + (d as MsgGreetings).headStatus === "Open") || + d.tag === "HeadIsOpen" + ); + }, + isClosed: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + (d.tag === "Greetings" && + (d as MsgGreetings).headStatus === "Closed") || + d.tag === "HeadIsClosed" + ); + }, + isContested: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "HeadIsContested"; + }, + isReadyToFanout: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + (d.tag === "Greetings" && + (d as MsgGreetings).headStatus === "FanoutPossible") || + d.tag === "ReadyToFanout" + ); + }, + isFinalized: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "HeadIsFinalized"; + }, + isFinalStatus: ({ event }) => { + assertEvent(event, "Message"); + const d = event.data as HydraServerOutput; + return ( + d.tag === "Greetings" && (d as MsgGreetings).headStatus === "Final" + ); + }, + + /** Error guards */ + isInvalidInput: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "InvalidInput"; + }, + isCommandFailed: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "CommandFailed"; + }, + isTxInvalid: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "TxInvalid"; + }, + isPostTxFailed: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "PostTxOnChainFailed"; + }, + isDecommitInvalid: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DecommitInvalid"; + }, + + /** Commit confirmation guards */ + isLegacyCommitEvent: ({ event }) => { + assertEvent(event, "Message"); + const t = (event.data as HydraServerOutput).tag; + return t === "Committed"; + }, + isIncrementalCommitEvent: ({ event }) => { + assertEvent(event, "Message"); + const t = (event.data as HydraServerOutput).tag; + return ( + t === "CommitRecorded" || + t === "CommitApproved" || + t === "CommitFinalized" || + t === "CommitRecovered" + ); + }, + + /** Deposit guards */ + isDepositRecorded: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DepositRecorded"; + }, + isDepositActivated: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DepositActivated"; + }, + isDepositExpired: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DepositExpired"; + }, + + /** Decommit guards */ + isDecommitRequested: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DecommitRequested"; + }, + isDecommitApproved: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DecommitApproved"; + }, + isDecommitFinalized: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "DecommitFinalized"; + }, + + /** Transaction guards */ + isTxValid: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "TxValid"; + }, + isSnapshotConfirmed: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "SnapshotConfirmed"; + }, + + /** Network guards */ + isNetworkConnected: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "NetworkConnected"; + }, + isNetworkDisconnected: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "NetworkDisconnected"; + }, + isPeerConnected: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "PeerConnected"; + }, + isPeerDisconnected: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "PeerDisconnected"; + }, + + /** Other guards */ + isIgnoredHeadInitializing: ({ event }) => { + assertEvent(event, "Message"); + return ( + (event.data as HydraServerOutput).tag === "IgnoredHeadInitializing" + ); + }, + isSnapshotSideLoaded: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "SnapshotSideLoaded"; + }, + isEventLogRotated: ({ event }) => { + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "EventLogRotated"; + }, + }, + + actors: { + /** Long-lived WS actor */ + server: fromCallback( + ({ sendBack, receive, input }) => { + const ws = webSocketFactory.create(input.url); + + ws.onopen = () => sendBack({ type: "Ready", connection: ws }); + ws.onerror = (error) => sendBack({ type: "Error", data: error }); + ws.onmessage = (event) => { + try { + sendBack({ type: "Message", data: JSON.parse(event.data) }); + } catch (e) { + sendBack({ type: "Error", data: e }); + } + }; + ws.onclose = (event) => + sendBack({ type: "Disconnect", code: event.code }); + + receive((event) => { + assertEvent(event, "Send"); + if (ws.readyState === 1) + // WebSocket.OPEN + ws.send(JSON.stringify(event.data)); + else + sendBack({ + type: "Error", + data: new Error("Connection is not open"), + }); + }); + + return () => { + try { + ws.close(); + } catch { + /* noop */ + } + }; + }, + ), + + requestDepositDraft: fromPromise< + Transaction, + { client?: HTTPClient; request: unknown } + >(async ({ input, signal }) => { + if (!input.client) throw new Error("Client is not initialized"); + if (!input.request) throw new Error("Request is not provided"); + const { client, request } = input; + const draft = (await client.post( + "/commit", + request, + undefined, + signal, + )) as Transaction; + return draft; + }), + + submitCardanoTx: fromPromise< + unknown, + { client?: HTTPClient; tx: Transaction; path?: string } + >(async ({ input, signal }) => { + if (!input.client) throw new Error("Client is not initialized"); + if (!input.tx) throw new Error("Signed transaction is not provided"); + const { client, tx, path } = input; + return await client.post( + path ?? "/cardano-transaction", + tx, + undefined, + signal, + ); + }), + }, + + types: { + context: {} as { + baseURL: string; + client?: HTTPClient; + connection?: WebSocket; + error?: HydraError | unknown; + headURL: string; + request?: unknown; + draftTx?: Transaction; + signedDepositTx?: Transaction; + submitPath?: string; + }, + events: {} as + | { + type: "Connect"; + baseURL: string; + address?: string; + snapshot?: boolean; + history?: boolean; + } + | { type: "Ready"; connection: WebSocket } + | { type: "Send"; data: unknown } + | { type: "Message"; data: HydraServerOutput } + | { type: "Error"; data: unknown } + | { type: "Disconnect"; code: number } + | { type: "Init" } + | { type: "Commit"; data: unknown } + | { type: "NewTx"; tx: Transaction } + | { type: "Recover"; txHash: string } + | { type: "Decommit"; tx: Transaction } + | { type: "Abort" } + | { type: "Contest" } + | { type: "Fanout" } + | { type: "Close" } + | { type: "SubmitSignedDeposit"; tx: Transaction } + | { type: "DepositSubmittedExternally" } + | { type: "SideLoadSnapshot"; snapshot: unknown }, + }, + }).createMachine({ + id: "HYDRA", + initial: "Disconnected", + context: { + baseURL: "", + headURL: "", + submitPath: "/cardano-transaction", + }, + + states: { + Disconnected: { + on: { + Connect: { target: "Connected", actions: "setURL" }, + }, + }, + + Connected: { + invoke: { + id: "server", + src: "server", + input: ({ context }) => ({ url: context.headURL }), + }, + + on: { + Message: [ + // Error handling + { guard: "isInvalidInput", actions: "captureServerError" }, + { guard: "isCommandFailed", actions: "captureServerError" }, + { guard: "isTxInvalid", actions: "captureServerError" }, + { guard: "isPostTxFailed", actions: "captureServerError" }, + { guard: "isDecommitInvalid", actions: "captureServerError" }, + + // Network events (can happen in any state) + { guard: "isNetworkConnected", actions: [] }, + { guard: "isNetworkDisconnected", actions: [] }, + { guard: "isPeerConnected", actions: [] }, + { guard: "isPeerDisconnected", actions: [] }, + + // Other events that can happen anytime + { guard: "isIgnoredHeadInitializing", actions: [] }, + { guard: "isEventLogRotated", actions: [] }, + + // Head state transitions + { guard: "isIdle", target: ".NoHead" }, + { guard: "isInitializing", target: ".Initializing" }, + { guard: "isOpen", target: ".Open" }, + { guard: "isClosed", target: ".Closed" }, + { guard: "isReadyToFanout", target: ".FanoutPossible" }, + // Contest updates the Closed state, doesn't create new state + { guard: "isAborted", target: ".Final" }, + { guard: "isFinalized", target: ".Final" }, + { guard: "isFinalStatus", target: ".Final" }, + ], + Disconnect: { target: "Disconnected", actions: "closeConnection" }, + Error: { actions: "setError" }, + }, + + initial: "Handshake", + states: { + Handshake: { + on: { + Ready: { + // Stay in handshake, just save connection + actions: ["setConnection", "createClient"], + }, + Message: [ + // Wait for Greetings to determine initial state + { guard: "isIdle", target: "NoHead" }, + { guard: "isInitializing", target: "Initializing" }, + { guard: "isOpen", target: "Open" }, + { guard: "isClosed", target: "Closed" }, + { guard: "isReadyToFanout", target: "FanoutPossible" }, + { guard: "isFinalStatus", target: "Final" }, + ], + }, + }, + + NoHead: { + on: { + Init: { actions: ["clearError", "initHead"] }, + }, + }, + + Initializing: { + on: { + Abort: { actions: ["clearError", "abortHead"] }, + // Recover and Decommit are only available in Open state + Message: [ + // Handle when other parties commit + { guard: "isCommitted", actions: [] }, + ], + }, + initial: "Waiting", + states: { + Waiting: { + // Waiting for user to commit or for head to open + on: { + Commit: { + target: + "#HYDRA.Connected.Initializing.Depositing.RequestDraft", + actions: ["clearError", "setRequest"], + }, + }, + }, + Depositing: { + initial: "ReadyToCommit" as const, + on: { + SubmitSignedDeposit: { target: ".SubmittingDeposit" }, + DepositSubmittedExternally: { + target: ".AwaitingCommitConfirmation", + }, + }, + states: { + ReadyToCommit: { + on: { + Commit: { + target: "RequestDraft", + actions: ["clearError", "setRequest"], + }, + }, + }, + + RequestDraft: { + invoke: { + src: "requestDepositDraft", + input: ({ context }: any) => ({ + client: context.client, + request: context.request, + }), + onDone: { + target: "AwaitSignature", + actions: assign(({ event }: any) => ({ + draftTx: event.output as Transaction, + })), + }, + onError: { + target: "ReadyToCommit", + actions: "setError", + }, + }, + }, + + AwaitSignature: { + on: { + SubmitSignedDeposit: { + target: "SubmittingDeposit", + actions: assign(({ event }: any) => { + assertEvent(event, "SubmitSignedDeposit"); + return { signedDepositTx: event.tx as Transaction }; + }), + }, + DepositSubmittedExternally: { + target: "AwaitingCommitConfirmation", + }, + }, + }, + + SubmittingDeposit: { + invoke: { + src: "submitCardanoTx", + input: ({ context }: any) => ({ + client: context.client, + tx: context.signedDepositTx as Transaction, + path: context.submitPath, + }), + onDone: { target: "AwaitingCommitConfirmation" }, + onError: { + target: "AwaitSignature", + actions: "setError", + }, + }, + }, + + AwaitingCommitConfirmation: { + on: { + Message: { + guard: "isLegacyCommitEvent", + target: "Done", + actions: ["clearRequest", "clearDraftTx"], + }, + }, + }, + + Done: { type: "final" as const }, + }, + onDone: { + // Return to Waiting after successful commit + target: "Waiting", + }, + }, + }, + }, + + Open: { + on: { + Close: { actions: ["clearError", "closeHead"] }, + NewTx: { actions: ["clearError", "newTx"] }, + Decommit: { actions: ["clearError", "decommitUTxO"] }, + Recover: { actions: ["clearError", "recoverUTxO"] }, + SideLoadSnapshot: { + actions: ["clearError", "sideLoadSnapshot"], + }, + Message: [ + // Handle deposit/commit events + { guard: "isCommitRecorded", actions: [] }, // Track pending deposits if needed for UI + { guard: "isCommitApproved", actions: [] }, // Server approved the commit + { guard: "isCommitFinalized", actions: [] }, // Deposit confirmed and UTxO updated + { guard: "isCommitRecovered", actions: [] }, // Deposit was recovered + { guard: "isDepositActivated", actions: [] }, + { guard: "isDepositExpired", actions: [] }, + // Handle decommit events + { guard: "isDecommitRequested", actions: [] }, + { guard: "isDecommitApproved", actions: [] }, + { guard: "isDecommitFinalized", actions: [] }, + // Handle transaction events + { guard: "isTxValid", actions: [] }, + { guard: "isSnapshotConfirmed", actions: [] }, + // Handle snapshot side-loading + { guard: "isSnapshotSideLoaded", actions: [] }, + ], + }, + initial: "Active", + states: { + Active: { + on: { + Commit: { + target: "#HYDRA.Connected.Open.Depositing.RequestDraft", + actions: ["clearError", "setRequest"], + }, + }, + }, + Depositing: { + initial: "ReadyToCommit" as const, + on: { + SubmitSignedDeposit: { target: ".SubmittingDeposit" }, + DepositSubmittedExternally: { + target: ".AwaitingCommitConfirmation", + }, + }, + states: { + ReadyToCommit: { + on: { + Commit: { + target: "RequestDraft", + actions: ["clearError", "setRequest"], + }, + }, + }, + + RequestDraft: { + invoke: { + src: "requestDepositDraft", + input: ({ context }: any) => ({ + client: context.client, + request: context.request, + }), + onDone: { + target: "AwaitSignature", + actions: assign(({ event }: any) => ({ + draftTx: event.output as Transaction, + })), + }, + onError: { + target: "ReadyToCommit", + actions: "setError", + }, + }, + }, + + AwaitSignature: { + on: { + SubmitSignedDeposit: { + target: "SubmittingDeposit", + actions: assign(({ event }: any) => { + assertEvent(event, "SubmitSignedDeposit"); + return { signedDepositTx: event.tx as Transaction }; + }), + }, + DepositSubmittedExternally: { + target: "AwaitingCommitConfirmation", + }, + }, + }, + + SubmittingDeposit: { + invoke: { + src: "submitCardanoTx", + input: ({ context }: any) => ({ + client: context.client, + tx: context.signedDepositTx as Transaction, + path: context.submitPath, + }), + onDone: { target: "AwaitingCommitConfirmation" }, + onError: { + target: "AwaitSignature", + actions: "setError", + }, + }, + }, + + AwaitingCommitConfirmation: { + on: { + Message: { + guard: "isIncrementalCommitEvent", + target: "Done", + actions: ["clearRequest", "clearDraftTx"], + }, + }, + }, + + Done: { type: "final" as const }, + }, + onDone: { + // Return to Active after successful incremental commit + target: "Active", + }, + }, + }, + }, + + Closed: { + on: { + Contest: { actions: ["clearError", "contestHead"] }, + Message: [ + { + guard: "isContested", + // Stay in Closed state, just update internal state + actions: [], + }, + ], + }, + }, + + FanoutPossible: { + on: { + Fanout: { actions: ["clearError", "fanoutHead"] }, + }, + }, + + Final: { + on: { + Init: { actions: ["clearError", "initHead"] }, + }, + }, + }, + }, + }, + }); +} + +export const machine = createHydraMachine(); diff --git a/packages/mesh-hydra/src/state-management/index.ts b/packages/mesh-hydra/src/state-management/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/mesh-hydra/src/utils/emitter.ts b/packages/mesh-hydra/src/utils/emitter.ts new file mode 100644 index 00000000..6d68e1b1 --- /dev/null +++ b/packages/mesh-hydra/src/utils/emitter.ts @@ -0,0 +1,30 @@ +export class Emitter void>> { + private listeners: { [K in keyof T]?: Set } = {}; + + on(event: K, callback: T[K]) { + (this.listeners[event] ??= new Set()).add(callback); + return () => this.off(event, callback); + } + + once(event: K, callback: T[K]) { + const onceFn = (...args: any) => { + this.off(event, onceFn as T[K]); + callback(...args); + }; + this.on(event, onceFn as T[K]); + } + + off(event: K, callback: T[K]) { + this.listeners[event]?.delete(callback); + } + + emit(event: K, ...args: Parameters) { + this.listeners[event]?.forEach((cb) => cb(...args)); + } + + clear() { + Object.keys(this.listeners).forEach((event) => { + this.listeners[event as K]?.clear(); + }); + } +} diff --git a/packages/mesh-hydra/src/utils/http.ts b/packages/mesh-hydra/src/utils/http.ts new file mode 100644 index 00000000..838c3db7 --- /dev/null +++ b/packages/mesh-hydra/src/utils/http.ts @@ -0,0 +1,63 @@ +import axios, { AxiosInstance, RawAxiosRequestHeaders } from "axios"; + +export class HTTPClient { + constructor(private baseURL: string) { + this._instance = axios.create({ + baseURL: this.baseURL, + }); + } + + async get(endpoint: string, signal?: AbortSignal) { + try { + const { data, status } = await this._instance.get(endpoint, { signal }); + if (status === 200 || status == 202) return data; + throw _parseError(data); + } catch (error) { + throw _parseError(error); + } + } + + async post(endpoint: string, payload: unknown, headers?: RawAxiosRequestHeaders, signal?: AbortSignal) { + try { + const { data, status } = await this._instance.post(endpoint, payload, { + headers: headers ?? { "Content-Type": "application/json" }, signal, + }); + if (status === 200 || status == 202) return data; + throw _parseError(data); + } catch (error) { + throw _parseError(error); + } + } + + async delete(endpoint: string, headers?: RawAxiosRequestHeaders, signal?: AbortSignal) { + try { + const { data, status } = await this._instance.delete(endpoint, { headers, signal }); + if (status === 200 || status == 202) return data; + throw _parseError(data); + } catch (error) { + throw _parseError(error); + } + } + + private readonly _instance: AxiosInstance; +} + +function _parseError(error: unknown): string { + if (!axios.isAxiosError(error)) { + return JSON.stringify(error); + } + + if (error.response) { + return JSON.stringify({ + data: error.response.data, + headers: error.response.headers, + status: error.response.status, + }); + } + + if (error.request && !(error.request instanceof XMLHttpRequest)) { + return JSON.stringify(error.request); + } + + return JSON.stringify({ code: error.code, message: error.message }); +}; diff --git a/packages/mesh-hydra/src/utils/index.ts b/packages/mesh-hydra/src/utils/index.ts index 00b69572..8f4c244b 100644 --- a/packages/mesh-hydra/src/utils/index.ts +++ b/packages/mesh-hydra/src/utils/index.ts @@ -1,2 +1,3 @@ +export * from "./http"; export * from "./parse-http-error"; export * from "./hydraScriptRef"; \ No newline at end of file diff --git a/packages/mesh-hydra/tests/integration/hydra-machine-integration.test.ts b/packages/mesh-hydra/tests/integration/hydra-machine-integration.test.ts new file mode 100644 index 00000000..5c976ea5 --- /dev/null +++ b/packages/mesh-hydra/tests/integration/hydra-machine-integration.test.ts @@ -0,0 +1,519 @@ +import { createActor } from "xstate"; +import { + createHydraMachine, + WebSocketFactory, +} from "../../src/state-management/hydra-machine"; +import WebSocket from "ws"; + +// WebSocket factory for Node.js environment +// @ts-ignore - Type compatibility between ws and DOM WebSocket +class NodeWebSocketFactory implements WebSocketFactory { + // @ts-ignore - Type compatibility between ws and DOM WebSocket + create(url: string): WebSocket { + const ws = new WebSocket(url); + // Add missing properties for compatibility + (ws as any).dispatchEvent = function (event: Event) { + return true; + }; + return ws as unknown as WebSocket; + } +} + +const HYDRA_NODES = { + alice: "http://localhost:4001", + bob: "http://localhost:4002", + carol: "http://localhost:4003", +}; + +function stateToString(value: any): string { + if (typeof value === "string") return value; + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return ""; + const k = keys[0] as keyof typeof obj; + const v = obj[k]; + return `${k}.${stateToString(v)}`; +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function waitForState( + actor: any, + targetState: string, + timeout = 10000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for state: ${targetState}`)); + }, timeout); + + const subscription = actor.subscribe((snapshot: any) => { + const currentState = stateToString(snapshot.value); + if (currentState.includes(targetState)) { + clearTimeout(timer); + subscription.unsubscribe(); + resolve(); + } + }); + }); +} + +describe("Pure Hydra State Machine Integration Tests", () => { + let aliceActor: any; + let bobActor: any; + let carolActor: any; + + beforeAll(async () => { + // Check if Hydra demo is running + try { + const fetch = (global as any).fetch || require("node-fetch"); + await fetch(HYDRA_NODES.alice, { timeout: 5000 }); + await fetch(HYDRA_NODES.bob, { timeout: 5000 }); + await fetch(HYDRA_NODES.carol, { timeout: 5000 }); + } catch (error) { + throw new Error( + "Hydra demo is not running. Please run: cd hydra_tmp/demo && ./run-docker.sh", + ); + } + }); + + beforeEach(() => { + // Setup Alice + const aliceMachine = createHydraMachine({ + // @ts-ignore - Type compatibility issue with ws vs DOM WebSocket + webSocketFactory: new NodeWebSocketFactory(), + }); + aliceActor = createActor(aliceMachine); + + // Setup Bob + const bobMachine = createHydraMachine({ + // @ts-ignore - Type compatibility issue with ws vs DOM WebSocket + webSocketFactory: new NodeWebSocketFactory(), + }); + bobActor = createActor(bobMachine); + + // Setup Carol + const carolMachine = createHydraMachine({ + // @ts-ignore - Type compatibility issue with ws vs DOM WebSocket + webSocketFactory: new NodeWebSocketFactory(), + }); + carolActor = createActor(carolMachine); + + aliceActor.start(); + bobActor.start(); + carolActor.start(); + }); + + afterEach(async () => { + // Cleanup connections + const actors = [aliceActor, bobActor, carolActor]; + + for (const actor of actors) { + if ( + actor && + stateToString(actor.getSnapshot().value) !== "Disconnected" + ) { + actor.send({ type: "Disconnect" }); + await delay(500); + } + actor?.stop(); + } + }); + + describe("Basic State Machine Operations", () => { + test("should connect to single node and receive Greetings", async () => { + expect(stateToString(aliceActor.getSnapshot().value)).toBe( + "Disconnected", + ); + + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: true, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); // Wait for Greetings + + const snapshot = aliceActor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`Alice final state: ${state}`); + + expect(state).toMatch(/Connected/); + expect(snapshot.context.client).toBeDefined(); + expect(snapshot.context.connection).toBeDefined(); + expect(snapshot.context.baseURL).toBe(HYDRA_NODES.alice); + }, 15000); + + test("should connect all three nodes simultaneously", async () => { + const connections = [ + { actor: aliceActor, url: HYDRA_NODES.alice, name: "Alice" }, + { actor: bobActor, url: HYDRA_NODES.bob, name: "Bob" }, + { actor: carolActor, url: HYDRA_NODES.carol, name: "Carol" }, + ]; + + // Connect all simultaneously + connections.forEach(({ actor, url }) => { + actor.send({ + type: "Connect", + baseURL: url, + history: false, + snapshot: false, + }); + }); + + // Wait for all to connect + await Promise.all( + connections.map(({ actor }) => waitForState(actor, "Connected")), + ); + + await delay(3000); // Wait for handshakes + + // Verify all connections + connections.forEach(({ actor, url, name }) => { + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`${name} state: ${state}`); + + expect(state).toMatch(/Connected/); + expect(snapshot.context.client).toBeDefined(); + expect(snapshot.context.connection).toBeDefined(); + expect(snapshot.context.baseURL).toBe(url); + }); + }, 20000); + }); + + describe("State Machine Message Handling", () => { + test("should track message flow through state machine", async () => { + const messages: any[] = []; + const stateChanges: string[] = []; + + // Capture initial state + stateChanges.push(stateToString(aliceActor.getSnapshot().value)); + + const subscription = aliceActor.subscribe((snapshot: any) => { + const state = stateToString(snapshot.value); + if (stateChanges[stateChanges.length - 1] !== state) { + stateChanges.push(state); + console.log(`State: ${state}`); + } + + // Try to capture messages from context + if (snapshot.context.lastMessage) { + const msg = snapshot.context.lastMessage; + if ( + !messages.find((m) => JSON.stringify(m) === JSON.stringify(msg)) + ) { + messages.push(msg); + console.log(`Message: ${msg.tag || "Unknown"}`); + } + } + }); + + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: true, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(3000); // Wait for messages + + subscription.unsubscribe(); + + console.log(`Total state changes: ${stateChanges.length}`); + console.log(`Total messages captured: ${messages.length}`); + + expect(stateChanges.length).toBeGreaterThan(1); + expect(stateChanges).toContain("Disconnected"); + expect(stateChanges.some((s) => s.includes("Connected.Handshake"))).toBe( + true, + ); + }, 15000); + + test("should handle WebSocket commands", async () => { + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); + + const initialSnapshot = aliceActor.getSnapshot(); + const initialState = stateToString(initialSnapshot.value); + + console.log(`Initial state: ${initialState}`); + + // Test different commands based on current state + if (initialState.includes("NoHead")) { + console.log("Testing Init command..."); + aliceActor.send({ type: "Init" }); + await delay(2000); + + const afterInitSnapshot = aliceActor.getSnapshot(); + const afterInitState = stateToString(afterInitSnapshot.value); + console.log(`After Init: ${afterInitState}`); + + // Should either move to Initializing or stay in NoHead with error + expect( + afterInitState.includes("Initializing") || + afterInitState.includes("NoHead") || + afterInitSnapshot.context.error, + ).toBeTruthy(); + } else if (initialState.includes("Initializing")) { + console.log("Head is initializing, testing Abort command..."); + aliceActor.send({ type: "Abort" }); + await delay(2000); + + const afterAbortSnapshot = aliceActor.getSnapshot(); + console.log(`After Abort: ${stateToString(afterAbortSnapshot.value)}`); + } else if (initialState.includes("Open")) { + console.log("Head is open, testing Close command..."); + aliceActor.send({ type: "Close" }); + await delay(2000); + + const afterCloseSnapshot = aliceActor.getSnapshot(); + console.log(`After Close: ${stateToString(afterCloseSnapshot.value)}`); + } + + // Verify actor is still functional + const finalSnapshot = aliceActor.getSnapshot(); + expect(finalSnapshot.context.connection).toBeDefined(); + }, 20000); + }); + + describe("Advanced State Machine Features", () => { + test("should handle multiple rapid commands", async () => { + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); + + const commands = ["Init", "Abort", "Init"]; + const results: string[] = []; + + for (const command of commands) { + console.log(`Sending command: ${command}`); + aliceActor.send({ type: command }); + await delay(1000); + + const snapshot = aliceActor.getSnapshot(); + const state = stateToString(snapshot.value); + results.push(state); + console.log(`After ${command}: ${state}`); + + if (snapshot.context.error) { + console.log(`Error after ${command}:`, snapshot.context.error); + } + } + + // Machine should remain functional + const finalSnapshot = aliceActor.getSnapshot(); + expect(finalSnapshot.context.connection).toBeDefined(); + expect(results.length).toBe(commands.length); + }, 25000); + + test("should handle network reconnection", async () => { + // Initial connection + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); + + const connectedSnapshot = aliceActor.getSnapshot(); + expect(stateToString(connectedSnapshot.value)).toMatch(/Connected/); + + // Disconnect + aliceActor.send({ type: "Disconnect" }); + await delay(1000); + + const disconnectedSnapshot = aliceActor.getSnapshot(); + expect(stateToString(disconnectedSnapshot.value)).toBe("Disconnected"); + + // Reconnect + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); + + const reconnectedSnapshot = aliceActor.getSnapshot(); + expect(stateToString(reconnectedSnapshot.value)).toMatch(/Connected/); + expect(reconnectedSnapshot.context.client).toBeDefined(); + expect(reconnectedSnapshot.context.connection).toBeDefined(); + + console.log("✅ Network reconnection successful"); + }, 20000); + }); + + describe("Error Handling", () => { + test("should handle connection to invalid URL", async () => { + const errorStates: any[] = []; + + const subscription = aliceActor.subscribe((snapshot: any) => { + if (snapshot.context.error) { + errorStates.push(snapshot.context.error); + console.log("Error captured:", snapshot.context.error); + } + }); + + aliceActor.send({ + type: "Connect", + baseURL: "http://invalid-hydra-node:9999", + history: false, + snapshot: false, + }); + + await delay(5000); // Wait for error + + subscription.unsubscribe(); + + const finalSnapshot = aliceActor.getSnapshot(); + const finalState = stateToString(finalSnapshot.value); + + console.log(`Final state after invalid connection: ${finalState}`); + + // Should remain disconnected or have error + expect( + finalState === "Disconnected" || finalSnapshot.context.error, + ).toBeTruthy(); + }, 10000); + + test("should handle context errors properly", async () => { + // Test that the state machine can handle and store errors + aliceActor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(aliceActor, "Connected"); + await delay(2000); + + // Send an invalid command that should cause an error + aliceActor.send({ type: "UnknownCommand" as any }); + await delay(1000); + + const snapshot = aliceActor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`State after unknown command: ${state}`); + + // Should remain in a valid state even after invalid command + expect(state).toMatch(/Connected/); + expect(snapshot.context.connection).toBeDefined(); + }, 8000); + }); + + describe("Concurrent Operations", () => { + test("should handle concurrent state machine operations", async () => { + const actors = [aliceActor, bobActor, carolActor]; + const urls = [HYDRA_NODES.alice, HYDRA_NODES.bob, HYDRA_NODES.carol]; + const names = ["Alice", "Bob", "Carol"]; + + // Connect all actors concurrently + const connectPromises = actors.map((actor, index) => { + actor.send({ + type: "Connect", + baseURL: urls[index], + history: false, + snapshot: false, + }); + return waitForState(actor, "Connected"); + }); + + await Promise.all(connectPromises); + await delay(3000); + + // Verify all are connected + actors.forEach((actor, index) => { + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + console.log(`${names[index]} final state: ${state}`); + + expect(state).toMatch(/Connected/); + expect(snapshot.context.client).toBeDefined(); + expect(snapshot.context.connection).toBeDefined(); + }); + + // Send commands to all concurrently + console.log("Sending Init commands to all actors..."); + actors.forEach((actor) => { + actor.send({ type: "Init" }); + }); + + await delay(3000); + + // Check final states + actors.forEach((actor, index) => { + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + console.log(`${names[index]} after Init: ${state}`); + + // Should be in some valid state + expect(snapshot.context.connection).toBeDefined(); + }); + }, 30000); + }); +}); + +// Prerequisites check +describe("Pure State Machine Prerequisites", () => { + test("should verify Hydra nodes are accessible", async () => { + const fetch = (global as any).fetch || require("node-fetch"); + const results = []; + + for (const [nodeName, nodeURL] of Object.entries(HYDRA_NODES)) { + try { + const response = await fetch(nodeURL, { + method: "GET", + timeout: 5000, + }); + results.push({ + node: nodeName, + url: nodeURL, + status: response.status, + accessible: true, + }); + } catch (error) { + results.push({ + node: nodeName, + url: nodeURL, + status: "ERROR", + accessible: false, + error: (error as Error).message, + }); + } + } + + console.log("Node accessibility results:", results); + + results.forEach((result) => { + expect(result.accessible).toBe(true); + }); + }, 15000); +}); diff --git a/packages/mesh-hydra/tests/integration/hydra-machine-simple-integration.test.ts b/packages/mesh-hydra/tests/integration/hydra-machine-simple-integration.test.ts new file mode 100644 index 00000000..991debd0 --- /dev/null +++ b/packages/mesh-hydra/tests/integration/hydra-machine-simple-integration.test.ts @@ -0,0 +1,411 @@ +import { createActor } from "xstate"; +import { + createHydraMachine, + WebSocketFactory, +} from "../../src/state-management/hydra-machine"; +import WebSocket from "ws"; + +// WebSocket factory for Node.js environment +// @ts-ignore - Type compatibility between ws and DOM WebSocket +class NodeWebSocketFactory implements WebSocketFactory { + // @ts-ignore - Type compatibility between ws and DOM WebSocket + create(url: string): WebSocket { + try { + const ws = new WebSocket(url); + // Add missing properties for compatibility + (ws as any).dispatchEvent = function (event: Event) { + return true; + }; + return ws as unknown as WebSocket; + } catch (error) { + // Return a mock failed WebSocket for invalid URLs + const mockWs = { + readyState: 3, // CLOSED + close: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onerror: null, + onopen: null, + onclose: null, + onmessage: null, + send: () => { + throw new Error("WebSocket is closed"); + }, + }; + // Trigger error event after short delay + setTimeout(() => { + if (mockWs.onerror) { + // Create a simple error event object for Node.js compatibility + const errorEvent = { + type: "error", + error: new Error("Invalid URL"), + message: "Invalid URL", + }; + (mockWs.onerror as any)(errorEvent); + } + }, 10); + return mockWs as unknown as WebSocket; + } + } +} + +/** + * Integration test for hydra-machine against a live Hydra node + * Prerequisites: Hydra demo should be running (./hydra_tmp/demo/run-docker.sh) + * This test connects to the actual Hydra nodes running on localhost + */ + +const HYDRA_NODES = { + alice: "http://localhost:4001", + bob: "http://localhost:4002", + carol: "http://localhost:4003", +}; + +const WEBSOCKET_TIMEOUT = 10000; // 10 seconds +const CONNECTION_DELAY = 2000; // 2 seconds for connection to establish + +function waitForState( + actor: any, + targetState: string, + timeout = 5000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timeout waiting for state: ${targetState}`)); + }, timeout); + + const subscription = actor.subscribe((snapshot: any) => { + const currentState = stateToString(snapshot.value); + if (currentState.includes(targetState)) { + clearTimeout(timer); + subscription.unsubscribe(); + resolve(); + } + }); + }); +} + +function stateToString(value: any): string { + if (typeof value === "string") return value; + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) return ""; + const k = keys[0] as keyof typeof obj; + const v = obj[k]; + return `${k}.${stateToString(v)}`; +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("Hydra Machine Integration Tests", () => { + let actor: any; + + beforeEach(() => { + const machine = createHydraMachine({ + // @ts-ignore - Type compatibility issue with ws vs DOM WebSocket + webSocketFactory: new NodeWebSocketFactory(), + }); + actor = createActor(machine); + actor.start(); + }); + + afterEach(() => { + if (actor) { + // Disconnect gracefully + if (actor.getSnapshot().value !== "Disconnected") { + actor.send({ type: "Disconnect", code: 1000 }); + } + actor.stop(); + } + }); + + describe("Connection to Live Hydra Node", () => { + test( + "should connect to Alice's node and receive Greetings", + async () => { + expect(stateToString(actor.getSnapshot().value)).toBe("Disconnected"); + + // Connect to Alice's node + actor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: true, + snapshot: false, + }); + + // Wait for connection to establish + await waitForState(actor, "Connected"); + + // Should be in Handshake initially + const snapshot = actor.getSnapshot(); + const currentState = stateToString(snapshot.value); + + console.log(`Connected to Alice. Current state: ${currentState}`); + expect(currentState).toMatch(/Connected/); + + // Wait a bit for the Greetings message + await delay(CONNECTION_DELAY); + + const finalSnapshot = actor.getSnapshot(); + const finalState = stateToString(finalSnapshot.value); + + console.log(`Final state after Greetings: ${finalState}`); + console.log(`Connection details:`, { + baseURL: finalSnapshot.context.baseURL, + headURL: finalSnapshot.context.headURL, + hasClient: !!finalSnapshot.context.client, + hasConnection: !!finalSnapshot.context.connection, + error: finalSnapshot.context.error, + }); + + // Should have moved past Handshake after receiving Greetings + expect(finalState).not.toBe("Connected.Handshake"); + expect(finalSnapshot.context.client).toBeDefined(); + expect(finalSnapshot.context.connection).toBeDefined(); + }, + WEBSOCKET_TIMEOUT, + ); + + test( + "should handle connection to all three nodes", + async () => { + const results: Array<{ node: string; state: string; error?: any }> = []; + + for (const [nodeName, nodeURL] of Object.entries(HYDRA_NODES)) { + try { + const machine = createHydraMachine({ + // @ts-ignore - Type compatibility issue with ws vs DOM WebSocket + webSocketFactory: new NodeWebSocketFactory(), + }); + const testActor = createActor(machine); + testActor.start(); + + testActor.send({ + type: "Connect", + baseURL: nodeURL, + history: false, + snapshot: false, + }); + + await waitForState(testActor, "Connected"); + await delay(CONNECTION_DELAY); + + const snapshot = testActor.getSnapshot(); + results.push({ + node: nodeName, + state: stateToString(snapshot.value), + error: snapshot.context.error, + }); + + testActor.send({ type: "Disconnect", code: 1000 }); + testActor.stop(); + } catch (error) { + results.push({ + node: nodeName, + state: "Failed", + error: (error as Error).message, + }); + } + } + + console.log("Connection results:", results); + + // All nodes should connect successfully + expect(results).toHaveLength(3); + results.forEach((result) => { + expect(result.state).toMatch(/Connected/); + expect(result.error).toBeUndefined(); + }); + }, + WEBSOCKET_TIMEOUT * 3, + ); + }); + + describe("Head State Detection", () => { + test( + "should detect current head status from Greetings", + async () => { + actor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: true, + snapshot: false, + }); + + await waitForState(actor, "Connected"); + await delay(CONNECTION_DELAY); + + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`Detected head state: ${state}`); + + // Should be in one of the expected head states + const validStates = [ + "Connected.NoHead", // No head exists (Idle) + "Connected.Initializing", // Head is being set up + "Connected.Open", // Head is active + "Connected.Closed", // Head is closed + "Connected.FanoutPossible", // Ready for fanout + "Connected.Final", // Head finalized + ]; + + const isValidState = validStates.some((validState) => + state.startsWith(validState), + ); + + expect(isValidState).toBe(true); + }, + WEBSOCKET_TIMEOUT, + ); + }); + + describe("WebSocket Message Handling", () => { + test( + "should receive and process WebSocket messages", + async () => { + const receivedMessages: any[] = []; + + // Subscribe to state changes to capture messages + const subscription = actor.subscribe((snapshot: any) => { + if (snapshot.context.lastMessage) { + receivedMessages.push(snapshot.context.lastMessage); + } + }); + + actor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: true, + snapshot: false, + }); + + await waitForState(actor, "Connected"); + await delay(5000); // Wait longer to receive more messages + + subscription.unsubscribe(); + + console.log(`Received ${receivedMessages.length} messages`); + console.log("Sample messages:", receivedMessages.slice(0, 3)); + + // Should have received at least the Greetings message (or connection was successful) + // Sometimes messages arrive after test completes, so we check state instead + const finalState = stateToString(actor.getSnapshot().value); + expect( + finalState.includes("Connected") || receivedMessages.length > 0, + ).toBe(true); + + // First message should typically be Greetings + if (receivedMessages.length > 0) { + const firstMessage = receivedMessages[0]; + expect(firstMessage).toHaveProperty("tag"); + } + }, + WEBSOCKET_TIMEOUT, + ); + }); + + describe("Error Handling", () => { + test("should handle connection to non-existent node", async () => { + actor.send({ + type: "Connect", + baseURL: "http://localhost:9999", // Non-existent port + history: false, + snapshot: false, + }); + + // Wait a bit for connection attempt + await delay(2000); + + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`State after failed connection: ${state}`); + console.log(`Error:`, snapshot.context.error); + + // Should remain in Disconnected state or have an error + expect(state === "Disconnected" || snapshot.context.error).toBeTruthy(); + }); + + test("should handle invalid WebSocket URL", async () => { + actor.send({ + type: "Connect", + baseURL: "invalid-url", + history: false, + snapshot: false, + }); + + await delay(2000); + + const snapshot = actor.getSnapshot(); + const state = stateToString(snapshot.value); + + console.log(`State after invalid URL: ${state}`); + console.log(`Error:`, snapshot.context.error); + + // Should remain disconnected or have error + expect( + state === "Disconnected" || + state.includes("Connected") || // May connect but fail later + snapshot.context.error, + ).toBeTruthy(); + }); + }); + + describe("API Integration", () => { + test( + "should create HTTP client with correct base URL", + async () => { + actor.send({ + type: "Connect", + baseURL: HYDRA_NODES.alice, + history: false, + snapshot: false, + }); + + await waitForState(actor, "Connected"); + await delay(CONNECTION_DELAY); + + const snapshot = actor.getSnapshot(); + + expect(snapshot.context.baseURL).toBe(HYDRA_NODES.alice); + expect(snapshot.context.client).toBeDefined(); + + // The client should be configured with the correct base URL + console.log("HTTP Client configured with:", snapshot.context.baseURL); + }, + WEBSOCKET_TIMEOUT, + ); + }); +}); + +// Helper test to verify the demo is running +describe("Hydra Demo Prerequisites", () => { + test("should be able to reach Hydra nodes", async () => { + const fetch = (global as any).fetch || require("node-fetch"); + + for (const [nodeName, nodeURL] of Object.entries(HYDRA_NODES)) { + try { + const response = await fetch(nodeURL, { + method: "GET", + timeout: 5000, + }); + console.log(`${nodeName} (${nodeURL}): ${response.status}`); + + // Hydra nodes can return 400, 404, or 405 for GET requests to root, which is fine + expect([200, 400, 404, 405]).toContain(response.status); + } catch (error) { + console.error( + `Failed to reach ${nodeName} at ${nodeURL}:`, + (error as Error).message, + ); + throw new Error( + `Hydra demo might not be running. Please run: cd hydra_tmp/demo && ./run-docker.sh`, + ); + } + } + }); +});