From 6e0af09c7f54495a3ca4f99db5a8f11e1b284e96 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sun, 15 Jun 2025 13:16:44 +0100 Subject: [PATCH 01/19] hydra state machine --- package-lock.json | 102 +++++--- packages/mesh-hydra/package.json | 5 +- packages/mesh-hydra/src/hydra-connection.ts | 4 +- packages/mesh-hydra/src/hydra-machine.ts | 269 ++++++++++++++++++++ packages/mesh-hydra/src/hydra-provider.ts | 6 +- 5 files changed, 338 insertions(+), 48 deletions(-) create mode 100644 packages/mesh-hydra/src/hydra-machine.ts diff --git a/package-lock.json b/package-lock.json index 157ff0466..6142eecf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21329,6 +21329,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", @@ -25551,6 +25552,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": { @@ -27404,6 +27406,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": { @@ -29049,6 +29052,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" @@ -29497,6 +29501,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", @@ -29509,6 +29514,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" @@ -35260,6 +35266,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", @@ -35270,12 +35277,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", @@ -35286,6 +35295,7 @@ "version": "3.0.21", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, "license": "CC0-1.0" }, "node_modules/speed-limiter": { @@ -37732,6 +37742,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", @@ -38860,6 +38871,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", @@ -38873,6 +38885,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": { @@ -38918,6 +38931,16 @@ "node": ">=4.0" } }, + "node_modules/xstate": { + "version": "5.19.4", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.19.4.tgz", + "integrity": "sha512-h1UMSYOB564NXqAI+VpXrxwaBdOJUh6LOStooQ+Rn/+gqJWtGBfjZn265BwFI8Mp4ZoOyoLDNf8X1yp3faBUZQ==", + "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", @@ -39105,7 +39128,7 @@ }, "packages/bitcoin": { "name": "@meshsdk/bitcoin", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", "bip174": "^3.0.0-rc.1", @@ -39392,7 +39415,7 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -39410,11 +39433,11 @@ }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/core": "1.9.0-beta.54" + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core": "1.9.0-beta.55" }, "devDependencies": { "@meshsdk/configs": "*", @@ -39425,15 +39448,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/core-cst": "1.9.0-beta.54", - "@meshsdk/provider": "1.9.0-beta.54", - "@meshsdk/react": "1.9.0-beta.54", - "@meshsdk/transaction": "1.9.0-beta.54", - "@meshsdk/wallet": "1.9.0-beta.54" + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core-cst": "1.9.0-beta.55", + "@meshsdk/provider": "1.9.0-beta.55", + "@meshsdk/react": "1.9.0-beta.55", + "@meshsdk/transaction": "1.9.0-beta.55", + "@meshsdk/wallet": "1.9.0-beta.55" }, "devDependencies": { "@meshsdk/configs": "*", @@ -39444,10 +39467,10 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", + "@meshsdk/common": "1.9.0-beta.55", "@sidan-lab/whisky-js-browser": "^1.0.1", "@sidan-lab/whisky-js-nodejs": "^1.0.1", "@types/base32-encoding": "^1.0.2", @@ -39457,7 +39480,7 @@ }, "devDependencies": { "@meshsdk/configs": "*", - "@meshsdk/provider": "1.9.0-beta.54", + "@meshsdk/provider": "1.9.0-beta.55", "@types/json-bigint": "^1.0.4", "eslint": "^8.57.0", "ts-jest": "^29.1.4", @@ -39467,7 +39490,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "^0.45.5", @@ -39478,7 +39501,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.4", "@harmoniclabs/uplc": "1.2.4", - "@meshsdk/common": "1.9.0-beta.54", + "@meshsdk/common": "1.9.0-beta.55", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -39497,11 +39520,12 @@ }, "packages/mesh-hydra": { "name": "@meshsdk/hydra", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/core-cst": "1.9.0-beta.54", - "axios": "^1.7.2" + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core-cst": "1.9.0-beta.55", + "axios": "^1.7.2", + "xstate": "^5.19.4" }, "devDependencies": { "@meshsdk/configs": "*", @@ -39564,11 +39588,11 @@ }, "packages/mesh-provider": { "name": "@meshsdk/provider", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/core-cst": "1.9.0-beta.54", + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core-cst": "1.9.0-beta.55", "@utxorpc/sdk": "^0.6.7", "@utxorpc/spec": "^0.16.0", "axios": "^1.7.2" @@ -39583,14 +39607,14 @@ }, "packages/mesh-react": { "name": "@meshsdk/react", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { "@fabianbormann/cardano-peer-connect": "^1.2.18", - "@meshsdk/bitcoin": "1.9.0-beta.54", - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/transaction": "1.9.0-beta.54", - "@meshsdk/wallet": "1.9.0-beta.54", + "@meshsdk/bitcoin": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/transaction": "1.9.0-beta.55", + "@meshsdk/wallet": "1.9.0-beta.55", "@meshsdk/web3-sdk": "0.0.26", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -39628,10 +39652,10 @@ }, "packages/mesh-svelte": { "name": "@meshsdk/svelte", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/core": "1.9.0-beta.54", + "@meshsdk/core": "1.9.0-beta.55", "bits-ui": "1.0.0-next.65" }, "devDependencies": { @@ -39657,14 +39681,14 @@ }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "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.54", - "@meshsdk/core-cst": "1.9.0-beta.54", + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core-cst": "1.9.0-beta.55", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -39677,12 +39701,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.54", - "@meshsdk/core-cst": "1.9.0-beta.54", - "@meshsdk/transaction": "1.9.0-beta.54", + "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/core-cst": "1.9.0-beta.55", + "@meshsdk/transaction": "1.9.0-beta.55", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -39695,7 +39719,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.54", + "version": "1.9.0-beta.55", "license": "Apache-2.0", "dependencies": { "chalk": "5.3.0", diff --git a/packages/mesh-hydra/package.json b/packages/mesh-hydra/package.json index 54df5259e..2b5c22498 100644 --- a/packages/mesh-hydra/package.json +++ b/packages/mesh-hydra/package.json @@ -29,7 +29,8 @@ "dependencies": { "@meshsdk/common": "1.9.0-beta.55", "@meshsdk/core-cst": "1.9.0-beta.55", - "axios": "^1.7.2" + "axios": "^1.7.2", + "xstate": "^5.19.4" }, "devDependencies": { "@meshsdk/configs": "*", @@ -38,4 +39,4 @@ "tsup": "^8.0.2", "typescript": "^5.3.3" } -} \ No newline at end of file +} diff --git a/packages/mesh-hydra/src/hydra-connection.ts b/packages/mesh-hydra/src/hydra-connection.ts index c0cc76ad2..2671ba67b 100644 --- a/packages/mesh-hydra/src/hydra-connection.ts +++ b/packages/mesh-hydra/src/hydra-connection.ts @@ -24,7 +24,7 @@ export class HydraConnection extends EventEmitter { this._eventEmitter = eventEmitter; } - async connect() { + connect() { if (this._status !== "IDLE") { return; } @@ -48,7 +48,7 @@ export class HydraConnection extends EventEmitter { }; } - async disconnect() { + disconnect() { if (this._status === "IDLE") { return; } diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts new file mode 100644 index 000000000..837ae37f3 --- /dev/null +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -0,0 +1,269 @@ +import { AnyEventObject, assertEvent, assign, EventObject, fromCallback, sendTo, setup } from "xstate"; + +export const hydra = setup({ + actions: { + 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" } }) + }, + setConnection: assign(({ event }) => { + assertEvent(event, "Ready") + return { connection: event.connection } + }), + closeConnection: ({ context }) => { + if (context.connection?.readyState === WebSocket.OPEN) { + context.connection.close(1000, "Client disconnected"); + } + return { baseURL: "", headURL: "", connection: undefined, error: undefined }; + }, + setURL: assign(({ event }) => { + assertEvent(event, "Connect") + const url = event.baseURL.replace("http", "ws"); + const history = `history=${event.history ? "yes" : "no"}`; + const snapshot = `snapshot-utxo=${event.snapshot ? "yes" : "no"}` + const address = event.address ? `&address=${event.address}` : ""; + return { + baseURL: event.baseURL, + headURL: `${url}/?${history}&${snapshot}${address}`, + } + }), + setError: assign(({ event }) => { + assertEvent(event, "Error") + return { error: event.data } + }), + }, + guards: { + isInitializing: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsInitializing"; + }, + isAborted: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsAborted"; + }, + isOpen: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsOpen"; + }, + isClosed: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsClosed"; + }, + isContested: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsContested"; + }, + isReadyToFanout: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "ReadyToFanout"; + }, + isFinalized: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "HeadIsFinalized"; + } + }, + actors: { + server: fromCallback(({ sendBack, receive, input }) => { + const ws = new WebSocket(input.url); + + ws.onopen = () => { + sendBack({ type: "Ready", connection: ws }) + }; + ws.onerror = (error) => { + sendBack({ type: "Error", data: error }); + }; + ws.onmessage = (event) => { + sendBack({ type: "Message", data: JSON.parse(event.data) }); + }; + ws.onclose = (event) => { + sendBack({ type: "Disconnect", code: event.code }); + }; + + receive((event) => { + assertEvent(event, "Send"); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event.data)); + else sendBack({ type: "Error", data: new Error("Connection is not open") }); + }); + + return () => ws.close(); + }) + }, + types: { + context: {} as { + baseURL: string, + headURL: string, + connection?: WebSocket, + error?: unknown, + }, + events: {} as + | { type: "Connect", baseURL: string, address?: string, snapshot?: boolean, history?: boolean } + | { type: "Ready", connection: WebSocket } + | { type: "Send", data: unknown } + | { type: "Message", data: { tag: string } } + | { type: "Error", data: unknown } + | { type: "Disconnect", code: number } + | { type: "Init" } + | { type: "Abort" } + | { type: "Close" } + | { type: "Contest" } + | { type: "Commit" } + | { type: "Greetings" } + | { type: "Fanout" } + | { type: "Close" } + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUgguHIY2arKyhhzyru7ysi6mFghqrbaqRi6yHBxazvK+-hhpGaGi5PRgAE4rpCu4fAA2hQBmGwC2uKlBLAsUOfGkBUXcpUgg5cKLVYhaSi64KtIcKoYqcbyeQTNQ9Syjb7AsaGFxqFyGNpKaYgU7pc60RbokKY8hQeiYMD5CBmB78QQvMSPapuOq2DhKeRKKyeDxacEITpKXCwhwTaRKbQtdwotHzWgMQgkDE0MlPCmVamIVm4KxeaQqCaCtr2DmNL7MrzyNSC+H9KZ+VGzM44ugQegAUTWGzlz0VoGqmq+jSa2lkOi08hcKg5H1kuDUgcjvxcnTU0lF1uxmUguAAkhBtmB6GnyMJXQrXkqEJ9DLhpLzGSpZI15JoOdIQbgOGp5AZ-ibOvZE4Fk6FUxms-QCxUix7EG4tOG2nJGS4K7JXKHPrgXEpES06uvgQnLWKZQO8yJ8tsCAAvWL47AAIw2su4ZULVPHNRb3P0JrcgpU1ZM5kQ64qKqrS1DoP52FYPZzAeEDpkeBAnuel7Dg+jxumOkgTu4Wi4F4Hz9AirgBhytQcN6kbqNoLYCpBe5JuKdpwS8iEXniw4uDwaFPuQbwli05bWIGhgTM0CjyCRYzkR4JofjRyJ0b2DGpgA8nwYBLIw2yCGAI6Ujxxa1CaPJtn8dadD+sgkV0NhMnU8axqogJaFBNoprBqnqShnHkqOz6YTU85fDGjSeJoQZMiRXIRuMsItMJLQgi5fYSrBmnafawR0LA97efKvn6S+hnToC871I4fwhv+NRmqq+hKC2DLwqFSVKalWmwAwunuv5bjwhG+iGAoHA1iMwkkR4QE1v0-q-EGTgtTB6TtZ1HGPvlvF2EBDLVjWXhkboYJVe4P6qg4w06CBTgWjMimLQAYvk5CkAArjQAAKghCNeQ4PU9r1dRh1RKLo5Y6Oq-y6IG0gkZ40i2E1m21Joa4Lbaqa-S972fQQ33ZgDfnVL8ZbVguiLBj+DiRVY0UTAidQTK0zkKdBaOwXdsQnjmR74wV-mluWlZ1jWCj1kd1m4fVgqqG4uiGPJN0s25uDs+QnM87xvw2HIdTNJqw3WN0VWMmWwaxq4jLDfUviWk9EBwOIpxrXpvFMtyarOJqcuMnoh29KBPJwg4jLwmRnjXVavZSmQrNO91nrqKqsL1SarSxeyVVtu+1ZwkK8Ygj+qOZBh6EE4gsLhsyALm055kkQC3yB44YHnYNhcXOQyUiHiseA2X+jKLr1dArIlW9MaBqDcGTTGuMkZt5iFD4FEPelzUVhBYGdRyx4tmj4gM+2NnYdNFdDjz3aK+89UWjuM2qhNK2woHXqsJQoi+j2BqYPnwOmZgJfGs2jwx-B7Lo6hxihgFMZREcg6yNiZGoH+sFczMVPKxKAACDLznDPtKubR+jbw5IyHC65YxyxUKbG+8sI6K37O5NSvMS5XywuuCM6h17NEChWEi7gvg-kZDWXQI9PCyCQUtdKmDCprkUDoT4sZ4zSFbJZI6etbBWDIlORcn4VBiIxq9D6sAvpZkkf5CYOFNQTABCCAUU4941E1DYDwCgdC8KnK2XRHNtgmMJsaVUowWjqE3HoHhN9viaE4QYFx1gxGZTgBfLi60sG-B5HLJkGp6ojxHiRRs4ZRjNCDJk7a4d9ys1wAAFQkLmOIiEIDeIAnCXCzg7BuE6G4RcHJgSv2cFRChMZqElKVgAZVVnwWAAALUgNBgh7AICsQ4kA6klgaeqZpsYugjGhhnRo5ibFkQZL1P4Yjhn5FGRMmggyCB2wADKkGJAshJztiyfBsk0m+ay2mbN6D8Js3TgYalcPgsR6BqCkEOIcYQhIACOz04n3J8o8l8zzGl6Dea0jZHS7AcB5AKRwFZJ6iOZq5Oh+AQVguENgPgfB1hxDhXlBFfNlmvJaes9pGdBplm6Y4NcwY3y7gVkSlKJKyBkpoFUmpiykUrNRSyz5+8Kw2B+NYAECUGSIiBaS8FNAVYsVpUw3ikqmXvPRVVJyQFFUmmBjWS2MTQWasJGQFYdtakPLjvUl5KLmUfI5BQuW2LrCCWNE4HwhLO6pmYCKilVLSA0udfC11Sz3WrLRayr5wJFDmuZBoMx-T6KLXDZq7VaDdXcX1Yyj1RqU3Kk1HDTlFZ1AVkBSG1q6IRX2ujasYtiTEVlqTTK71+hwyKtnJqBxTN+WhtSra4QXcMEut7gm5FvavUmuBnwgUwNPCGnUNbbwQA */ + id: "HYDRA", + initial: "Disconnected", + context: { + baseURL: "", + headURL: "", + }, + states: { + Disconnected: { + on: { + Connect: { + target: "Connection", + actions: "setURL" + } + } + }, + Connection: { + invoke: { + src: "server", + input: ({ context }) => ({ + url: context.headURL, + }), + onDone: "Connected", + onError: "Disconnected" + }, + initial: "Connecting", + states: { + Connecting: { + on: { + Ready: { + target: "Done", + actions: "setConnection" + } + } + }, + Done: { type: "final" } + } + }, + Connected: { + on: { + Disconnect: { + target: "Disconnected", + actions: "closeConnection" + }, + Error: { actions: "setError" } + }, + initial: "Idle", + states: { + Idle: { + on: { + Init: { + actions: "initHead", + reenter: true + } + }, + always: { + target: "Initializing", + guard: "isInitializing" + } + }, + Initializing: { + on: { + Abort: { + actions: "abortHead", + reenter: true + }, + }, + always: [{ + target: "Open", + guard: "isOpen" + }, { + target: "Final", + guard: "isAborted", + reenter: true + }] + }, + Open: { + on: { + Close: { + actions: "closeHead", + reenter: true, + }, + }, + always: { + target: "Closed", + guard: "isClosed" + } + }, + Closed: { + on: { + Contest: { + actions: "contestHead", + reenter: true + } + }, + always: [{ + target: "Contested", + guard: "isContested" + }, { + target: "FanoutPossible", + guard: "isReadyToFanout" + }] + }, + FanoutPossible: { + on: { + Fanout: { + actions: "fanoutHead", + reenter: true + } + }, + always: { + target: "Final", + guard: "isFinalized" + } + }, + Final: { + on: { + Init: { + actions: "initHead", + reenter: true + } + }, + always: { + target: "Initializing", + guard: "isInitializing" + } + }, + Contested: {}, + TxInvalid: {}, + SnapshotConfirmed: {}, + SnapshotSideLoaded: {}, + DecommitRequested: {}, + DecommitApproved: {}, + DecommitInvalid: {}, + DecommitFinalized: {}, + CommitRecorded: {}, + CommitApproved: {}, + CommitFinalized: {}, + CommitRecovered: {}, + Committing: {} + } + }, + } +}); diff --git a/packages/mesh-hydra/src/hydra-provider.ts b/packages/mesh-hydra/src/hydra-provider.ts index f03039048..04a3ea2c9 100644 --- a/packages/mesh-hydra/src/hydra-provider.ts +++ b/packages/mesh-hydra/src/hydra-provider.ts @@ -89,11 +89,7 @@ export class HydraProvider implements IFetcher, ISubmitter { /** * Connects to the Hydra Head. This command is a no-op when a Head is already open. */ - async connect() { - if (this._status !== "DISCONNECTED") { - return; - } - this._status = "CONNECTING"; + connect() { this._connection.connect(); } From 3236736adaf65f040053850dfb6e4d31c0414c1d Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sun, 15 Jun 2025 20:21:37 +0100 Subject: [PATCH 02/19] handle Greetings message --- packages/mesh-hydra/src/hydra-machine.ts | 65 +++++++++++++----------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index 837ae37f3..35e5a9c70 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -1,4 +1,4 @@ -import { AnyEventObject, assertEvent, assign, EventObject, fromCallback, sendTo, setup } from "xstate"; +import { AnyEventObject, assertEvent, assign, fromCallback, sendTo, setup } from "xstate"; export const hydra = setup({ actions: { @@ -46,6 +46,9 @@ export const hydra = setup({ guards: { isInitializing: ({ event }) => { assertEvent(event, "Message") + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Initializing"; + } return event.data.tag === "HeadIsInitializing"; }, isAborted: ({ event }) => { @@ -54,10 +57,16 @@ export const hydra = setup({ }, isOpen: ({ event }) => { assertEvent(event, "Message") + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Open"; + } return event.data.tag === "HeadIsOpen"; }, isClosed: ({ event }) => { assertEvent(event, "Message") + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Closed"; + } return event.data.tag === "HeadIsClosed"; }, isContested: ({ event }) => { @@ -66,6 +75,9 @@ export const hydra = setup({ }, isReadyToFanout: ({ event }) => { assertEvent(event, "Message") + if (event.data.tag === "Greetings") { + return event.data.headStatus === "FanoutPossible"; + } return event.data.tag === "ReadyToFanout"; }, isFinalized: ({ event }) => { @@ -110,20 +122,18 @@ export const hydra = setup({ | { type: "Connect", baseURL: string, address?: string, snapshot?: boolean, history?: boolean } | { type: "Ready", connection: WebSocket } | { type: "Send", data: unknown } - | { type: "Message", data: { tag: string } } + | { type: "Message", data: { [x: string]: unknown, tag: string } } | { type: "Error", data: unknown } | { type: "Disconnect", code: number } | { type: "Init" } | { type: "Abort" } | { type: "Close" } | { type: "Contest" } - | { type: "Commit" } - | { type: "Greetings" } | { type: "Fanout" } | { type: "Close" } }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUgguHIY2arKyhhzyru7ysi6mFghqrbaqRi6yHBxazvK+-hhpGaGi5PRgAE4rpCu4fAA2hQBmGwC2uKlBLAsUOfGkBUXcpUgg5cKLVYhaSi64KtIcKoYqcbyeQTNQ9Syjb7AsaGFxqFyGNpKaYgU7pc60RbokKY8hQeiYMD5CBmB78QQvMSPapuOq2DhKeRKKyeDxacEITpKXCwhwTaRKbQtdwotHzWgMQgkDE0MlPCmVamIVm4KxeaQqCaCtr2DmNL7MrzyNSC+H9KZ+VGzM44ugQegAUTWGzlz0VoGqmq+jSa2lkOi08hcKg5H1kuDUgcjvxcnTU0lF1uxmUguAAkhBtmB6GnyMJXQrXkqEJ9DLhpLzGSpZI15JoOdIQbgOGp5AZ-ibOvZE4Fk6FUxms-QCxUix7EG4tOG2nJGS4K7JXKHPrgXEpES06uvgQnLWKZQO8yJ8tsCAAvWL47AAIw2su4ZULVPHNRb3P0JrcgpU1ZM5kQ64qKqrS1DoP52FYPZzAeEDpkeBAnuel7Dg+jxumOkgTu4Wi4F4Hz9AirgBhytQcN6kbqNoLYCpBe5JuKdpwS8iEXniw4uDwaFPuQbwli05bWIGhgTM0CjyCRYzkR4JofjRyJ0b2DGpgA8nwYBLIw2yCGAI6Ujxxa1CaPJtn8dadD+sgkV0NhMnU8axqogJaFBNoprBqnqShnHkqOz6YTU85fDGjSeJoQZMiRXIRuMsItMJLQgi5fYSrBmnafawR0LA97efKvn6S+hnToC871I4fwhv+NRmqq+hKC2DLwqFSVKalWmwAwunuv5bjwhG+iGAoHA1iMwkkR4QE1v0-q-EGTgtTB6TtZ1HGPvlvF2EBDLVjWXhkboYJVe4P6qg4w06CBTgWjMimLQAYvk5CkAArjQAAKghCNeQ4PU9r1dRh1RKLo5Y6Oq-y6IG0gkZ40i2E1m21Joa4Lbaqa-S972fQQ33ZgDfnVL8ZbVguiLBj+DiRVY0UTAidQTK0zkKdBaOwXdsQnjmR74wV-mluWlZ1jWCj1kd1m4fVgqqG4uiGPJN0s25uDs+QnM87xvw2HIdTNJqw3WN0VWMmWwaxq4jLDfUviWk9EBwOIpxrXpvFMtyarOJqcuMnoh29KBPJwg4jLwmRnjXVavZSmQrNO91nrqKqsL1SarSxeyVVtu+1ZwkK8Ygj+qOZBh6EE4gsLhsyALm055kkQC3yB44YHnYNhcXOQyUiHiseA2X+jKLr1dArIlW9MaBqDcGTTGuMkZt5iFD4FEPelzUVhBYGdRyx4tmj4gM+2NnYdNFdDjz3aK+89UWjuM2qhNK2woHXqsJQoi+j2BqYPnwOmZgJfGs2jwx-B7Lo6hxihgFMZREcg6yNiZGoH+sFczMVPKxKAACDLznDPtKubR+jbw5IyHC65YxyxUKbG+8sI6K37O5NSvMS5XywuuCM6h17NEChWEi7gvg-kZDWXQI9PCyCQUtdKmDCprkUDoT4sZ4zSFbJZI6etbBWDIlORcn4VBiIxq9D6sAvpZkkf5CYOFNQTABCCAUU4941E1DYDwCgdC8KnK2XRHNtgmMJsaVUowWjqE3HoHhN9viaE4QYFx1gxGZTgBfLi60sG-B5HLJkGp6ojxHiRRs4ZRjNCDJk7a4d9ys1wAAFQkLmOIiEIDeIAnCXCzg7BuE6G4RcHJgSv2cFRChMZqElKVgAZVVnwWAAALUgNBgh7AICsQ4kA6klgaeqZpsYugjGhhnRo5ibFkQZL1P4Yjhn5FGRMmggyCB2wADKkGJAshJztiyfBsk0m+ay2mbN6D8Js3TgYalcPgsR6BqCkEOIcYQhIACOz04n3J8o8l8zzGl6Dea0jZHS7AcB5AKRwFZJ6iOZq5Oh+AQVguENgPgfB1hxDhXlBFfNlmvJaes9pGdBplm6Y4NcwY3y7gVkSlKJKyBkpoFUmpiykUrNRSyz5+8Kw2B+NYAECUGSIiBaS8FNAVYsVpUw3ikqmXvPRVVJyQFFUmmBjWS2MTQWasJGQFYdtakPLjvUl5KLmUfI5BQuW2LrCCWNE4HwhLO6pmYCKilVLSA0udfC11Sz3WrLRayr5wJFDmuZBoMx-T6KLXDZq7VaDdXcX1Yyj1RqU3Kk1HDTlFZ1AVkBSG1q6IRX2ujasYtiTEVlqTTK71+hwyKtnJqBxTN+WhtSra4QXcMEut7gm5FvavUmuBnwgUwNPCGnUNbbwQA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUgguHIY2arKyhhzyru7ysi6mFghqrbaqRi6yHBxazvK+-hhpGaGi5PRgAE4rpCu4fAA2hQBmGwC2uKlBLAsUOfGkBUXcpUgg5cKLVYhaSi64KtIcKoYqcbyeQTNQ9Syjb7AsaGFxqFyGNpKaYgU7pc60RbokKY8hQeiYMD5CBmB78QQvMSPapuOq2DhKeRKKyeDxacEITpKXCwhwTaRKbQtdwotHzWgMACycFg+RgZKeFMq1MQI0MWlwTIZsk0LlUTNkHMZhlwch1OkBeuZLlFszOOLoEHo0tgsvlLh4j2eytANOaKk1sn0zMcHi8Kg5bg8uFk0kR0nhKj1HltgWxmSlMrlYDYsk95IqrxVNSsJq08gMelqAOskZU5e+jlqmmB+htflRdvToUzruzbHk+cVhapvsQaiUHGUBh1zlcWlq0kj0nkNlagMcDJ+rXbMzT4sd9EIJAxNAV3qLY4QrJjxusdQZsJ1HJ+JtGnxXLaUK7U0lTc1PBgAFE1g2c8lUvSRVUBWQY1+Jx6l-etDXMRB-i+OxDFjINK2kH4tH-e0MwgXAAEkIG2MB6FI8hhHAkdyDeBBdA1VlGjaXQXHUbpUOYpllFULjw30etkQ7MVAJI8jKPoejKUY4s3ARXAOCaJpV3kNxPhQ3oPgDVS-k3bR7BUQjuwlKTaJEfJtgIAAvWJ8WwAAjDYz24MoINHKCag4OEY36BN1PLcMjWcFT5CTBMVwURld07fdJLIqyCBs+zHNkjyvS8hSrzcBQVKiloNBaAVIzGAMmSChNEX6KZxK7A9IGSl40ocvFZI9TyGKYoYoQ4CcKpbEFyr+TVv0aGrV1aMympIgB5PgwCWRhtkEHMsoLeSmNqfykMMRkBU8djIy6bkulGIMRgXfQ-waxKHWaxblsyocL28mkRm5SK2W3VSdXkSNgS+YE40FLctAUO69wAx6SNW9anWCOhYHct6cp2sZuQO+EEVhOoNAjXi4T1FTYW-QE5G0SLZqShHYAYOSfR82lzoG6mONaA7Iw8aQVO0AmJjqBF4okuH0jWhmnTYLrsp64tIanYZ7Dw98yuJ3n+f0eohdhdVafFgAxfJyFIABXGgAAVBCEZyZON02LaZyDqkZAMkwUJF-gtOtAW+EMNIOy6DeI3AHfNq2bYIO2qOdj73jsFSqeZVTEREoHNM1NoDsnVRIbaEOexIw3Yhs6irLj3KfJYqF7ChzjuNO0mugRX8vHqQuLLD0vtle7rtoVyHNXsIVtNbsFeMEnk5BUQFPknOF6o7U2IDgcRTn75nqiZc6736FolCfInelqDVeQnIWXATBloYStJjzIOHN5dtD1Bjcn2a5hF2V4ituQ8LoF96grg8KZe6sNMiQXelXaoT5lD+lcKoIEz5iYAm+HCCc-pYpxk7lkcyIg8TP3jggdUU5mQAkQYCcsKDeiri+IfLoKh1L9HsARcBRELjkHwFEIhMDVRWC+HoCsThtCeEnMfRA01bBMKEp0ZotU1C4MgLwpiahOjThnnOdwi4OSOADLGP4Oo6iqGZLIJRUkKJgBUQrNB8jPC6BXCCHiulPiml+LPFwwJ+gTjEjDDhXcaKtVsu1KA1i8pfVsJDQUugDF1CNF4U0TR6x4T8oKLo5jcDPSrtAnaXENQIn+u4VkVhAbEyKXBREFC2zlkUew-BzV6bKLlgPcJftOgVjUVhAETDSkn3rIodQB9GiLz8rUvx9Ti4mwjtbV00dKJhJ8vYRQSlEQ-ARDEpQdYPjfE-E4-4kU1BsPGXNbu5AbILOqBMehI9Z4AgRNTSM5TYxpzGKApkRy77+MdNiFGjoLmqhGHzMMq51CQx1LWYmzJFCTl+CuPGzQ7AZIACoSBonENKEB-k1EwbgdwDgnF2BYTpCEeEAoKFngZCcAoMkAGUzl8FgAAC1IDQYIewCArEOE0raW9VSRT5n8Vo-QDCMnsByJopLZCNClUmFOBhaX0qZSymlBBV4ABlSDEm5cOFpLMRiKBqlfA0C53HiuBCaX8XE-LwRuhk9A1BSCHEOMIQkABHM2cA-nNN5din4mp3CuFnKDTwZqEmWs0nqVwQqxmfImfgB1TrhDYD4HwdYcRtU5MUg4L4IrPCriDL+Vo4qNyalxmI-Czg7UJudTQNFGKsWeMcDGbRzzPDoV6ZYc1uBw1aSjauGNYtQ72rIImmgJcznBIzRjRSkUAzdIOcCD2Hhi0DVLW4ctehK11JOcwUdhIyArFXpi71L8agjBsL8NROExhX3+OKi66D1QKC4hOdQYDjl00dTW5NqbSDpuPTy09japxCyYXUEpwD70jEfYGg6s8gwZN3TW8dbUp3y3CVYXF+c7BSsbZoe9kr2IUtGFS3xsad1fpdQ69NKw0O6ppHycamlOhX0cL+H+vQmjQaTM4NUqgmEfMHUXdEo6CGhJPcQtwE5lCHM+JDBwDJOgrpsHQgagJhZ4t8L4IAA */ id: "HYDRA", initial: "Disconnected", context: { @@ -163,6 +173,19 @@ export const hydra = setup({ }, Connected: { on: { + Message: [{ + target: ".Initializing", + guard: "isInitializing", + }, { + target: ".Open", + guard: "isOpen", + }, { + target: ".Closed", + guard: "isClosed", + }, { + target: ".FanoutPossible", + guard: "isReadyToFanout", + }], Disconnect: { target: "Disconnected", actions: "closeConnection" @@ -173,10 +196,7 @@ export const hydra = setup({ states: { Idle: { on: { - Init: { - actions: "initHead", - reenter: true - } + Init: { actions: "initHead" } }, always: { target: "Initializing", @@ -185,10 +205,7 @@ export const hydra = setup({ }, Initializing: { on: { - Abort: { - actions: "abortHead", - reenter: true - }, + Abort: { actions: "abortHead" } }, always: [{ target: "Open", @@ -196,15 +213,11 @@ export const hydra = setup({ }, { target: "Final", guard: "isAborted", - reenter: true }] }, Open: { on: { - Close: { - actions: "closeHead", - reenter: true, - }, + Close: { actions: "closeHead" } }, always: { target: "Closed", @@ -213,10 +226,7 @@ export const hydra = setup({ }, Closed: { on: { - Contest: { - actions: "contestHead", - reenter: true - } + Contest: { actions: "contestHead" } }, always: [{ target: "Contested", @@ -228,10 +238,7 @@ export const hydra = setup({ }, FanoutPossible: { on: { - Fanout: { - actions: "fanoutHead", - reenter: true - } + Fanout: { actions: "fanoutHead" } }, always: { target: "Final", @@ -240,16 +247,14 @@ export const hydra = setup({ }, Final: { on: { - Init: { - actions: "initHead", - reenter: true - } + Init: { actions: "initHead" } }, always: { target: "Initializing", guard: "isInitializing" } }, + Contested: {}, TxInvalid: {}, SnapshotConfirmed: {}, From 811345755c4e549be13bf850944f3f6c6956ba0d Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Fri, 20 Jun 2025 11:34:14 +0100 Subject: [PATCH 03/19] Commit WIP --- packages/mesh-hydra/src/hydra-machine.ts | 111 ++++++++++++++++++----- 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index 35e5a9c70..0448a52a3 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -1,4 +1,6 @@ +import axios, { AxiosInstance, type RawAxiosRequestHeaders } from "axios"; import { AnyEventObject, assertEvent, assign, fromCallback, sendTo, setup } from "xstate"; +import { parseHttpError } from "./utils"; export const hydra = setup({ actions: { @@ -17,10 +19,6 @@ export const hydra = setup({ fanoutHead: () => { sendTo("server", { type: "Send", data: { "tag": "Fanout" } }) }, - setConnection: assign(({ event }) => { - assertEvent(event, "Ready") - return { connection: event.connection } - }), closeConnection: ({ context }) => { if (context.connection?.readyState === WebSocket.OPEN) { context.connection.close(1000, "Client disconnected"); @@ -38,6 +36,17 @@ export const hydra = setup({ headURL: `${url}/?${history}&${snapshot}${address}`, } }), + setConnection: assign(({ event }) => { + assertEvent(event, "Ready") + return { connection: event.connection } + }), + createClient: assign(({ context }) => { + return { client: new Client(context.baseURL) } + }), + setRequest: assign(({ event }) => { + assertEvent(event, ["Commit"]) + return { request: event.data } + }), setError: assign(({ event }) => { assertEvent(event, "Error") return { error: event.data } @@ -55,6 +64,10 @@ export const hydra = setup({ assertEvent(event, "Message") return event.data.tag === "HeadIsAborted"; }, + isCommitted: ({ event }) => { + assertEvent(event, "Message") + return event.data.tag === "Committed"; + }, isOpen: ({ event }) => { assertEvent(event, "Message") if (event.data.tag === "Greetings") { @@ -114,9 +127,11 @@ export const hydra = setup({ types: { context: {} as { baseURL: string, - headURL: string, + client?: Client, connection?: WebSocket, error?: unknown, + headURL: string, + request?: unknown, }, events: {} as | { type: "Connect", baseURL: string, address?: string, snapshot?: boolean, history?: boolean } @@ -126,6 +141,7 @@ export const hydra = setup({ | { type: "Error", data: unknown } | { type: "Disconnect", code: number } | { type: "Init" } + | { type: "Commit", data: unknown } | { type: "Abort" } | { type: "Close" } | { type: "Contest" } @@ -133,7 +149,7 @@ export const hydra = setup({ | { type: "Close" } }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUgguHIY2arKyhhzyru7ysi6mFghqrbaqRi6yHBxazvK+-hhpGaGi5PRgAE4rpCu4fAA2hQBmGwC2uKlBLAsUOfGkBUXcpUgg5cKLVYhaSi64KtIcKoYqcbyeQTNQ9Syjb7AsaGFxqFyGNpKaYgU7pc60RbokKY8hQeiYMD5CBmB78QQvMSPapuOq2DhKeRKKyeDxacEITpKXCwhwTaRKbQtdwotHzWgMACycFg+RgZKeFMq1MQI0MWlwTIZsk0LlUTNkHMZhlwch1OkBeuZLlFszOOLoEHo0tgsvlLh4j2eytANOaKk1sn0zMcHi8Kg5bg8uFk0kR0nhKj1HltgWxmSlMrlYDYsk95IqrxVNSsJq08gMelqAOskZU5e+jlqmmB+htflRdvToUzruzbHk+cVhapvsQaiUHGUBh1zlcWlq0kj0nkNlagMcDJ+rXbMzT4sd9EIJAxNAV3qLY4QrJjxusdQZsJ1HJ+JtGnxXLaUK7U0lTc1PBgAFE1g2c8lUvSRVUBWQY1+Jx6l-etDXMRB-i+OxDFjINK2kH4tH-e0MwgXAAEkIG2MB6FI8hhHAkdyDeBBdA1VlGjaXQXHUbpUOYpllFULjw30etkQ7MVAJI8jKPoejKUY4s3ARXAOCaJpV3kNxPhQ3oPgDVS-k3bR7BUQjuwlKTaJEfJtgIAAvWJ8WwAAjDYz24MoINHKCag4OEY36BN1PLcMjWcFT5CTBMVwURld07fdJLIqyCBs+zHNkjyvS8hSrzcBQVKiloNBaAVIzGAMmSChNEX6KZxK7A9IGSl40ocvFZI9TyGKYoYoQ4CcKpbEFyr+TVv0aGrV1aMympIgB5PgwCWRhtkEHMsoLeSmNqfykMMRkBU8djIy6bkulGIMRgXfQ-waxKHWaxblsyocL28mkRm5SK2W3VSdXkSNgS+YE40FLctAUO69wAx6SNW9anWCOhYHct6cp2sZuQO+EEVhOoNAjXi4T1FTYW-QE5G0SLZqShHYAYOSfR82lzoG6mONaA7Iw8aQVO0AmJjqBF4okuH0jWhmnTYLrsp64tIanYZ7Dw98yuJ3n+f0eohdhdVafFgAxfJyFIABXGgAAVBCEZyZON02LaZyDqkZAMkwUJF-gtOtAW+EMNIOy6DeI3AHfNq2bYIO2qOdj73jsFSqeZVTEREoHNM1NoDsnVRIbaEOexIw3Yhs6irLj3KfJYqF7ChzjuNO0mugRX8vHqQuLLD0vtle7rtoVyHNXsIVtNbsFeMEnk5BUQFPknOF6o7U2IDgcRTn75nqiZc6736FolCfInelqDVeQnIWXATBloYStJjzIOHN5dtD1Bjcn2a5hF2V4ituQ8LoF96grg8KZe6sNMiQXelXaoT5lD+lcKoIEz5iYAm+HCCc-pYpxk7lkcyIg8TP3jggdUU5mQAkQYCcsKDeiri+IfLoKh1L9HsARcBRELjkHwFEIhMDVRWC+HoCsThtCeEnMfRA01bBMKEp0ZotU1C4MgLwpiahOjThnnOdwi4OSOADLGP4Oo6iqGZLIJRUkKJgBUQrNB8jPC6BXCCHiulPiml+LPFwwJ+gTjEjDDhXcaKtVsu1KA1i8pfVsJDQUugDF1CNF4U0TR6x4T8oKLo5jcDPSrtAnaXENQIn+u4VkVhAbEyKXBREFC2zlkUew-BzV6bKLlgPcJftOgVjUVhAETDSkn3rIodQB9GiLz8rUvx9Ti4mwjtbV00dKJhJ8vYRQSlEQ-ARDEpQdYPjfE-E4-4kU1BsPGXNbu5AbILOqBMehI9Z4AgRNTSM5TYxpzGKApkRy77+MdNiFGjoLmqhGHzMMq51CQx1LWYmzJFCTl+CuPGzQ7AZIACoSBonENKEB-k1EwbgdwDgnF2BYTpCEeEAoKFngZCcAoMkAGUzl8FgAAC1IDQYIewCArEOE0raW9VSRT5n8Vo-QDCMnsByJopLZCNClUmFOBhaX0qZSymlBBV4ABlSDEm5cOFpLMRiKBqlfA0C53HiuBCaX8XE-LwRuhk9A1BSCHEOMIQkABHM2cA-nNN5din4mp3CuFnKDTwZqEmWs0nqVwQqxmfImfgB1TrhDYD4HwdYcRtU5MUg4L4IrPCriDL+Vo4qNyalxmI-Czg7UJudTQNFGKsWeMcDGbRzzPDoV6ZYc1uBw1aSjauGNYtQ72rIImmgJcznBIzRjRSkUAzdIOcCD2Hhi0DVLW4ctehK11JOcwUdhIyArFXpi71L8agjBsL8NROExhX3+OKi66D1QKC4hOdQYDjl00dTW5NqbSDpuPTy09japxCyYXUEpwD70jEfYGg6s8gwZN3TW8dbUp3y3CVYXF+c7BSsbZoe9kr2IUtGFS3xsad1fpdQ69NKw0O6ppHycamlOhX0cL+H+vQmjQaTM4NUqgmEfMHUXdEo6CGhJPcQtwE5lCHM+JDBwDJOgrpsHQgagJhZ4t8L4IAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VNwcQy2VryUaqTqTMEIeRWXDtBxaWHacbuZGoxa0BgAWTgsHyMFJL3JlSpiDqHBcA30zmkTImdjZehUyk0jkMSk6amk8hmfhR8wu2LoEHoEtgUplLh4z1eCtA1JcTm+gNpk0BclkbLcvIazUM9nUWhN0yFNqxmXFkulYDYsndZIq70VNV9NhVLg4HDGMJUENB5iVkyUuDNal5ahchukk0TgWToVTzvTbHk2blucp3sQmi0uGG7ZGo15skrYfN9QX7VGKn+JYhnYW6Pt9EIJD3ss9ebHvU6uCsXmstNa7QXbJUMKvQ3NmkZ5rNO9tKYdAFENi2E95TPSQZERWxrCBcsxkRJ8X1kN95A-JQv2kH9u1FCBcAASQgXYwHoXDyGEECR3ID4EAcfpZBjCYOXvORdUrZQ5A4M17EMZ8NEwkV7TwgiiPIilKPzdwVH1QEPCcLQSwcaw2TjOkOkGSsEQ4LQ6NkPi90gPDSJEfJdgIAAvWI8WwAAjLYaBEr1wIQRE1EUDgLUad9ERNNkTUUBRHF9DkXInXS7X0ki3mMsyLPoeywOpMt6w3JCIUrAUXDDUs6TUCY3BhVpn3sUK-wMyKTPM3FYrdMpQNHRzpnqJQSyUTT2kK+xMtpBtcohcY4SKq1hT0nCIqM8qLNwAkiTMAAVUhmGOY5hCYUhFrI7gaooqjgTjBsXMBX5zWcVkawQX4bCBdtaSOzUnGKnsRsMggooqqA0TWmgREquK6uqJD5EUZ8mt9QZajqNluPrYtZARJwWsk+7sNwAB5PgwBWRhdkEDMNo9WqxPPP5+maBESxjKwETDRFFA5bQJMrDpPERgTUfR2LcZzUTtrcxQ5DsblQbNMMAcndR1E1Lwo3NZn9Mx7GHWCOhYDsjnhy5-M2oaewUJ0FqRnNMMET8gqPE-eMZZwuXYAYH6CccrdFBBdUy0mZx-jDewVzLON9A435-gt9Iseth02GqvGtvzBFJxLQwvDo5UkOrHp3ARV86O0UsQTjnTBqTfj9IAMXychSAAVxoAAFQQhCswj6GL0uK9tqijoGP4m2fX19GkMNJJsI06jNDoIQcQPG-Lqua4IOvhNV09fsQYfcHUIFWl+P44-kYXLzFs0TScRxpbzrsC5wwvYmM4jDJb-M41VJqdByjxoWTpVqYaCnn1cSTzS0cfL67HZkOBedtqhNkhO2fQXcVDcQcNvU61gkpaS7vIc0ExNK+CtKXCAcBxDnE2urc8jJ9SqBhu2RoDhZDyUypObkGgERXTgvITCh4yBhQgIQhy1R+5XjXH7Ny7RoxskaPWDw1CnbND-gjE+u47RgVAVRB8ygxhd1UJpDkKgwzcRXsCDQf9fQjDjszTE-ELJcPiogaM-RRjcVcOowEj5ToWgfn8Vs1Nn5NhMRQfAUQLGLxqFYVUehGhw1NvDEROVbAblbADRETgLSWjmKfYa-iwHjg5AachngOKkw4myI0DQ0KaViXoTSudklyJKvhQiaSqJ9CyWaHJVCaGnR9soYpQJ7Clg5BU60KSOGlTGtFXEdTxKwNVP8LSLlWg+20CxOhCgJggkGGWYxsjfwPSGc9cauJJqEmJHNBaS0aBjPPMCLwV49Aw1EXMroiCUIryWTCXyCIrCB1GjskZb1jnCC+lAM5jkkIqH6CyQYLi0IbjUD5JsV46iwLgvDFCHynovQmugPxEciH2xhDYEF1N-hWDSjC-U0YYZuTcGlXkgdWZ20UeJDiqozQTENB0JsbgwzuDpF4Rwt5piqUDlbSAgKErqG+LTJocdph912o2J+Mz-hJP6VUrZE8K7V2dDPWpWLuFLxBCvNxcls6DCFqdVs5oGzjG9pMTwvo+lDUGRfcgxkRVL2cFeSYBhhhuCEQglOXLIyOBymg7idR7X52GliJW9pXU1B5rgMsFomqeGoU1P1SomRcgUHYFxG5Bi518EAA */ id: "HYDRA", initial: "Disconnected", context: { @@ -155,7 +171,10 @@ export const hydra = setup({ input: ({ context }) => ({ url: context.headURL, }), - onDone: "Connected", + onDone: { + target: "Connected", + actions: "createClient" + }, onError: "Disconnected" }, initial: "Connecting", @@ -213,7 +232,27 @@ export const hydra = setup({ }, { target: "Final", guard: "isAborted", - }] + }], + initial: "ReadyToCommit", + states: { + ReadyToCommit: { + on: { + Commit: { + target: "Committing", + actions: "setRequest" + } + } + }, + Committing: { + always: { + target: "Done", + guard: "isCommitted" + } + }, + Done: { + type: "final" + } + }, }, Open: { on: { @@ -254,21 +293,51 @@ export const hydra = setup({ guard: "isInitializing" } }, - Contested: {}, - TxInvalid: {}, - SnapshotConfirmed: {}, - SnapshotSideLoaded: {}, - DecommitRequested: {}, - DecommitApproved: {}, - DecommitInvalid: {}, - DecommitFinalized: {}, - CommitRecorded: {}, - CommitApproved: {}, - CommitFinalized: {}, - CommitRecovered: {}, - Committing: {} + // TxInvalid: {}, + // SnapshotConfirmed: {}, + // SnapshotSideLoaded: {}, + // DecommitRequested: {}, + // DecommitApproved: {}, + // DecommitInvalid: {}, + // DecommitFinalized: {}, + // CommitRecorded: {}, + // CommitApproved: {}, + // CommitFinalized: {}, + // CommitRecovered: {}, } }, } }); + +class Client { + constructor(private baseURL: string) { + this._instance = axios.create({ + baseURL: this.baseURL, + }); + } + + async get(endpoint: string) { + try { + const { data, status } = await this._instance.get(endpoint); + if (status === 200 || status == 202) return data; + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async post(endpoint: string, payload: unknown, headers?: RawAxiosRequestHeaders) { + try { + const { data, status } = await this._instance.post(endpoint, payload, { + headers: headers ?? { "Content-Type": "application/json" } + }); + if (status === 200 || status == 202) return data; + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + private readonly _instance: AxiosInstance; +} From b42de4c3c1921f2862534fe98e4885029a46ee3b Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Thu, 10 Jul 2025 09:25:09 +0100 Subject: [PATCH 04/19] install --- package-lock.json | 168 +++++++++++++++++++++++++++++++++------------- 1 file changed, 121 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6142eecf2..b08662b29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15513,20 +15513,94 @@ "link": true }, "node_modules/@meshsdk/web3-sdk": { - "version": "0.0.26", - "resolved": "https://registry.npmjs.org/@meshsdk/web3-sdk/-/web3-sdk-0.0.26.tgz", - "integrity": "sha512-HCEOXYeeE569S1T4nILhu9E00an8ya+jtrn5t85NIWYwNxmMhoqjSrxfx5tfEXSN1g2SukuDqViAUZPLuwQjHg==", + "version": "0.0.37", + "resolved": "https://registry.npmjs.org/@meshsdk/web3-sdk/-/web3-sdk-0.0.37.tgz", + "integrity": "sha512-uRG0jLjsa83JbPZqnVkec3gjvi0LEMiu1E6ItUALEnKUTTuhDOe3Cx4Ov1PbPTsYVsGRq61DCgzCNHSh2bXy+Q==", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "^1.9.0-beta.35", - "@meshsdk/core-cst": "^1.9.0-beta.35", - "@meshsdk/wallet": "^1.9.0-beta.35", + "@meshsdk/bitcoin": "1.9.0-beta.53", + "@meshsdk/common": "1.9.0-beta.53", + "@meshsdk/core-cst": "1.9.0-beta.53", + "@meshsdk/wallet": "1.9.0-beta.53", "@peculiar/webcrypto": "^1.5.0", "axios": "^1.8.3", "base32-encoding": "^1.0.0", "uuid": "^11.1.0" } }, + "node_modules/@meshsdk/web3-sdk/node_modules/@meshsdk/bitcoin": { + "version": "1.9.0-beta.53", + "resolved": "https://registry.npmjs.org/@meshsdk/bitcoin/-/bitcoin-1.9.0-beta.53.tgz", + "integrity": "sha512-nl6+UZT05vpWUT+Vic2IkhfeJPZlNHm0zvDlOmos5u2JcC1li9T0QmMjYLvyaSj0u29Q0v+iRR4fvF0a8RZTQA==", + "dependencies": { + "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", + "bip174": "^3.0.0-rc.1", + "bip32": "^4.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.7", + "ecpair": "^2.0.0" + } + }, + "node_modules/@meshsdk/web3-sdk/node_modules/@meshsdk/common": { + "version": "1.9.0-beta.53", + "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.53.tgz", + "integrity": "sha512-GH75W2P4LPb8MS/F+ftP1wmf2UhSYsug9Naq09bvEU1woohJLmpkJ6JJ1e9fBbAK/N3VRVQEGvv+yM4zs634rQ==", + "license": "Apache-2.0", + "dependencies": { + "bech32": "^2.0.0", + "bip39": "3.1.0", + "blake2b": "^2.1.4", + "blakejs": "^1.2.1" + } + }, + "node_modules/@meshsdk/web3-sdk/node_modules/@meshsdk/core-cst": { + "version": "1.9.0-beta.53", + "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.0-beta.53.tgz", + "integrity": "sha512-u8I1g8EqfI+ysCtMg258NrMZ+uoSdM5RlrfVRuss0a7jsrSB64ae1kZXDaO2HROycpwz+muZbCVN5JywSVKmTQ==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/core": "^0.45.5", + "@cardano-sdk/crypto": "^0.2.2", + "@cardano-sdk/input-selection": "^0.13.33", + "@cardano-sdk/util": "^0.15.5", + "@harmoniclabs/cbor": "1.3.0", + "@harmoniclabs/pair": "^1.0.0", + "@harmoniclabs/plutus-data": "1.2.4", + "@harmoniclabs/uplc": "1.2.4", + "@meshsdk/common": "1.9.0-beta.53", + "@types/base32-encoding": "^1.0.2", + "base32-encoding": "^1.0.0", + "bech32": "^2.0.0", + "blakejs": "^1.2.1", + "bn.js": "^5.2.0" + } + }, + "node_modules/@meshsdk/web3-sdk/node_modules/@meshsdk/transaction": { + "version": "1.9.0-beta.53", + "resolved": "https://registry.npmjs.org/@meshsdk/transaction/-/transaction-1.9.0-beta.53.tgz", + "integrity": "sha512-U53sj8Qve9/XQPqy6gaO7Sm57Fq0tGcYcTlIUq2XUOZtVV0ad88qvCakj9AG0uSq0WnrvPk+L0ExmnnzyL/akw==", + "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.53", + "@meshsdk/core-cst": "1.9.0-beta.53", + "json-bigint": "^1.0.0" + } + }, + "node_modules/@meshsdk/web3-sdk/node_modules/@meshsdk/wallet": { + "version": "1.9.0-beta.53", + "resolved": "https://registry.npmjs.org/@meshsdk/wallet/-/wallet-1.9.0-beta.53.tgz", + "integrity": "sha512-UyvcRbh3StEowkjTyuomDqG5Pykgbz3PU84gB9LdbxGuX46ZUaqHa5+zq+TfOreJ9CVvIEPkg7YwLas6c1HqMw==", + "license": "Apache-2.0", + "dependencies": { + "@meshsdk/common": "1.9.0-beta.53", + "@meshsdk/core-cst": "1.9.0-beta.53", + "@meshsdk/transaction": "1.9.0-beta.53", + "@simplewebauthn/browser": "^13.0.0" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", @@ -39128,7 +39202,7 @@ }, "packages/bitcoin": { "name": "@meshsdk/bitcoin", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", "bip174": "^3.0.0-rc.1", @@ -39415,7 +39489,7 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -39433,11 +39507,11 @@ }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/core": "1.9.0-beta.55" + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core": "1.9.0-beta.62" }, "devDependencies": { "@meshsdk/configs": "*", @@ -39448,15 +39522,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/core-cst": "1.9.0-beta.55", - "@meshsdk/provider": "1.9.0-beta.55", - "@meshsdk/react": "1.9.0-beta.55", - "@meshsdk/transaction": "1.9.0-beta.55", - "@meshsdk/wallet": "1.9.0-beta.55" + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core-cst": "1.9.0-beta.62", + "@meshsdk/provider": "1.9.0-beta.62", + "@meshsdk/react": "1.9.0-beta.62", + "@meshsdk/transaction": "1.9.0-beta.62", + "@meshsdk/wallet": "1.9.0-beta.62" }, "devDependencies": { "@meshsdk/configs": "*", @@ -39467,12 +39541,12 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@sidan-lab/whisky-js-browser": "^1.0.1", - "@sidan-lab/whisky-js-nodejs": "^1.0.1", + "@meshsdk/common": "1.9.0-beta.62", + "@sidan-lab/whisky-js-browser": "^1.0.5", + "@sidan-lab/whisky-js-nodejs": "^1.0.5", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -39480,7 +39554,7 @@ }, "devDependencies": { "@meshsdk/configs": "*", - "@meshsdk/provider": "1.9.0-beta.55", + "@meshsdk/provider": "1.9.0-beta.62", "@types/json-bigint": "^1.0.4", "eslint": "^8.57.0", "ts-jest": "^29.1.4", @@ -39490,7 +39564,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "^0.45.5", @@ -39501,7 +39575,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.4", "@harmoniclabs/uplc": "1.2.4", - "@meshsdk/common": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.62", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -39520,10 +39594,10 @@ }, "packages/mesh-hydra": { "name": "@meshsdk/hydra", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/core-cst": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core-cst": "1.9.0-beta.62", "axios": "^1.7.2", "xstate": "^5.19.4" }, @@ -39588,11 +39662,11 @@ }, "packages/mesh-provider": { "name": "@meshsdk/provider", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/core-cst": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core-cst": "1.9.0-beta.62", "@utxorpc/sdk": "^0.6.7", "@utxorpc/spec": "^0.16.0", "axios": "^1.7.2" @@ -39607,15 +39681,15 @@ }, "packages/mesh-react": { "name": "@meshsdk/react", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { "@fabianbormann/cardano-peer-connect": "^1.2.18", - "@meshsdk/bitcoin": "1.9.0-beta.55", - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/transaction": "1.9.0-beta.55", - "@meshsdk/wallet": "1.9.0-beta.55", - "@meshsdk/web3-sdk": "0.0.26", + "@meshsdk/bitcoin": "1.9.0-beta.62", + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/transaction": "1.9.0-beta.62", + "@meshsdk/wallet": "1.9.0-beta.62", + "@meshsdk/web3-sdk": "0.0.37", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.2", @@ -39652,10 +39726,10 @@ }, "packages/mesh-svelte": { "name": "@meshsdk/svelte", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/core": "1.9.0-beta.55", + "@meshsdk/core": "1.9.0-beta.62", "bits-ui": "1.0.0-next.65" }, "devDependencies": { @@ -39681,14 +39755,14 @@ }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "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.55", - "@meshsdk/core-cst": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core-cst": "1.9.0-beta.62", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -39701,12 +39775,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.55", - "@meshsdk/core-cst": "1.9.0-beta.55", - "@meshsdk/transaction": "1.9.0-beta.55", + "@meshsdk/common": "1.9.0-beta.62", + "@meshsdk/core-cst": "1.9.0-beta.62", + "@meshsdk/transaction": "1.9.0-beta.62", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -39719,7 +39793,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.55", + "version": "1.9.0-beta.62", "license": "Apache-2.0", "dependencies": { "chalk": "5.3.0", From f30062b9b1268250e1c96d9c00947e9184a595dc Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Fri, 11 Jul 2025 11:16:21 +0100 Subject: [PATCH 05/19] commit funds --- packages/mesh-hydra/src/hydra-machine.ts | 43 +++++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index 0448a52a3..c88d9226b 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, type RawAxiosRequestHeaders } from "axios"; -import { AnyEventObject, assertEvent, assign, fromCallback, sendTo, setup } from "xstate"; +import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; import { parseHttpError } from "./utils"; export const hydra = setup({ @@ -43,13 +43,16 @@ export const hydra = setup({ createClient: assign(({ context }) => { return { client: new Client(context.baseURL) } }), + setError: assign(({ event }) => { + assertEvent(event, "Error") + return { error: event.data } + }), setRequest: assign(({ event }) => { assertEvent(event, ["Commit"]) return { request: event.data } }), - setError: assign(({ event }) => { - assertEvent(event, "Error") - return { error: event.data } + clearRequest: assign(() => { + return { request: undefined } }), }, guards: { @@ -122,6 +125,16 @@ export const hydra = setup({ }); return () => ws.close(); + }), + commit: fromPromise(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; + return await client.post("/commit", request, undefined, signal); }) }, types: { @@ -149,7 +162,7 @@ export const hydra = setup({ | { type: "Close" } }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VNwcQy2VryUaqTqTMEIeRWXDtBxaWHacbuZGoxa0BgAWTgsHyMFJL3JlSpiDqHBcA30zmkTImdjZehUyk0jkMSk6amk8hmfhR8wu2LoEHoEtgUplLh4z1eCtA1JcTm+gNpk0BclkbLcvIazUM9nUWhN0yFNqxmXFkulYDYsndZIq70VNV9NhVLg4HDGMJUENB5iVkyUuDNal5ahchukk0TgWToVTzvTbHk2blucp3sQmi0uGG7ZGo15skrYfN9QX7VGKn+JYhnYW6Pt9EIJD3ss9ebHvU6uCsXmstNa7QXbJUMKvQ3NmkZ5rNO9tKYdAFENi2E95TPSQZERWxrCBcsxkRJ8X1kN95A-JQv2kH9u1FCBcAASQgXYwHoXDyGEECR3ID4EAcfpZBjCYOXvORdUrZQ5A4M17EMZ8NEwkV7TwgiiPIilKPzdwVH1QEPCcLQSwcaw2TjOkOkGSsEQ4LQ6NkPi90gPDSJEfJdgIAAvWI8WwAAjLYaBEr1wIQRE1EUDgLUad9ERNNkTUUBRHF9DkXInXS7X0ki3mMsyLPoeywOpMt6w3JCIUrAUXDDUs6TUCY3BhVpn3sUK-wMyKTPM3FYrdMpQNHRzpnqJQSyUTT2kK+xMtpBtcohcY4SKq1hT0nCIqM8qLNwAkiTMAAVUhmGOY5hCYUhFrI7gaooqjgTjBsXMBX5zWcVkawQX4bCBdtaSOzUnGKnsRsMggooqqA0TWmgREquK6uqJD5EUZ8mt9QZajqNluPrYtZARJwWsk+7sNwAB5PgwBWRhdkEDMNo9WqxPPP5+maBESxjKwETDRFFA5bQJMrDpPERgTUfR2LcZzUTtrcxQ5DsblQbNMMAcndR1E1Lwo3NZn9Mx7GHWCOhYDsjnhy5-M2oaewUJ0FqRnNMMET8gqPE-eMZZwuXYAYH6CccrdFBBdUy0mZx-jDewVzLON9A435-gt9Iseth02GqvGtvzBFJxLQwvDo5UkOrHp3ARV86O0UsQTjnTBqTfj9IAMXychSAAVxoAAFQQhCswj6GL0uK9tqijoGP4m2fX19GkMNJJsI06jNDoIQcQPG-Lqua4IOvhNV09fsQYfcHUIFWl+P44-kYXLzFs0TScRxpbzrsC5wwvYmM4jDJb-M41VJqdByjxoWTpVqYaCnn1cSTzS0cfL67HZkOBedtqhNkhO2fQXcVDcQcNvU61gkpaS7vIc0ExNK+CtKXCAcBxDnE2urc8jJ9SqBhu2RoDhZDyUypObkGgERXTgvITCh4yBhQgIQhy1R+5XjXH7Ny7RoxskaPWDw1CnbND-gjE+u47RgVAVRB8ygxhd1UJpDkKgwzcRXsCDQf9fQjDjszTE-ELJcPiogaM-RRjcVcOowEj5ToWgfn8Vs1Nn5NhMRQfAUQLGLxqFYVUehGhw1NvDEROVbAblbADRETgLSWjmKfYa-iwHjg5AachngOKkw4myI0DQ0KaViXoTSudklyJKvhQiaSqJ9CyWaHJVCaGnR9soYpQJ7Clg5BU60KSOGlTGtFXEdTxKwNVP8LSLlWg+20CxOhCgJggkGGWYxsjfwPSGc9cauJJqEmJHNBaS0aBjPPMCLwV49Aw1EXMroiCUIryWTCXyCIrCB1GjskZb1jnCC+lAM5jkkIqH6CyQYLi0IbjUD5JsV46iwLgvDFCHynovQmugPxEciH2xhDYEF1N-hWDSjC-U0YYZuTcGlXkgdWZ20UeJDiqozQTENB0JsbgwzuDpF4Rwt5piqUDlbSAgKErqG+LTJocdph912o2J+Mz-hJP6VUrZE8K7V2dDPWpWLuFLxBCvNxcls6DCFqdVs5oGzjG9pMTwvo+lDUGRfcgxkRVL2cFeSYBhhhuCEQglOXLIyOBymg7idR7X52GliJW9pXU1B5rgMsFomqeGoU1P1SomRcgUHYFxG5Bi518EAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VNwcQy2VryUaqTqTMEIeRWXDtBxaWHacbuZGoxa0BgAWTgsHyMFJL3JlSpiDqHBcA30zmkTImdjZehUyk0jkMSk6amk8hmfhR8wu2LoEHoEtgUplLh4z1eCtA1JcTm+gNpk0BclkbLcvIazUM9nUWhN0yFNqxmXFkulYDYsndZIq70VNV9NhVLg4HDGMJUENB5iVkyUuDNal5ahchukk0TgWToVTzvTbHk2blucp3sQmi0uGG7ZGo15skrYfN9QX7VGKn+JYhnYW6Pt9EIJD3ss9ebHvU6uCsXmstNa7QXbJUMKvQ3NmkZ5rNO9tKYdAFENi2E95TPSQZERWxrCBcsxkRJ8X1kN95A-JQv2kH9u1FCBcAASQgXYwHoXDyGEECR3ID4EAcfpZBjCYOXvORdUrZQ5A4M17EMZ8NEwkV7TwgiiPIilKPzdwVH1QEPCcLQSwcaw2TjOkOkGDkjFUBw+L3SA8NIkR8l2AgAC9YjxbAACMthoESvXAhBES+KxBkRZ89UMeQ2W0SE3EkjoQS8CEVG0u1dJIt5DJMsz6FssDqTLesNyQoLfWLMNSzpNQJjcGFWmfewQr-PSIqM0zcRit0ylA0d7OmeolBLJQOF5Vs9HsdLaQbbKIXGOECqtYUdJw8KDNKszcAJIkzAAFVIZhjmOYQmFIBayO4KqKKoiFZFVQYOj+aYnAUNQvJ0KdW0kySONLLSBqTfiwv0ghIrKqA0VWmgRHK9ZNm2PZDhOM57qG4rRqi3F3sWz6zJuPJCmWYpYpq6kEUUOQm3nE15A5asei3SFnLkxoPJUFVCp7Yanpe8b5qhr68SRsTz19CEDQ4jQkMBTp0tYlVKxBTQYRQ8nsNwAB5PgwBWRhdkEDN1o9aqmfsv5+maBESxjKwETDRFFBxuSPErDpPBFgSJalmKFZzUSto4bGBjolqzRVM0w2xyd1HUTUvCjc0zd0mW5YdYI6FgGzreHW383afVTRQnQmpGc0w1R19VA8T94wDnCg9gBhGbtjoG20O8F15ORpDDewVzLON9A435-hz9JZfzh02EqxXNvzBFJxLDy-jkpCkNxpUa9fOjS8bjzZBbgAxfJyFIABXGgAAVBCECzCPoRfl7Xwv83NOkYVkJtn19fQq5rGpJJsI06jNDoIVuuYuwenD99Xjet4IHfhKR1PMjRAz9cDqCBK0X4fwPKeVvh0S8XszQmicI4f2d0P4g3nrEQyxF9JH3PHGVUDUdBZQ8NCMeNQ9YNG1m5C65otALxwbsK2Q5gHK2qE2SE7Z9CXxUNxBwcCejWESloBcrh5DmgmM1XwVpl4QDgOIc4G1o7nkZPqVQ592yNAcDtDi6VJzcg0PGRE9hrCYUPGQUKEAVF2WqPfK8a5G723aNGNkjR6weB2iCZsDDJJmzAuwqiD5lBjEvqoZqHIVBhm4uA4EagLTaDon4gJFAsL01sXFRA0Z+ijG4q4CJgJHy3wtMQv4rY9ZkKbKk8g+AoiZJAVQhqapibGkzk1aJJSsq2A3K2bGiInAWktO-Xc1iGkcPHByA0WjPDsz0SdW+RoGhoQmPzaMC5hnWkwdYwShFxlUT6NMs0szdHySUtoZQKygT2FLByOeGDRlFRGs9MauJ9niUmPrDyZidB1EkpQ3QdI5L3xauoWELdnnUwhpNYks1abCHeczFsqovCmm8n8zQXlxi4FpFuVo9cTRKAhVTV5b14XQzed3VR9lUo2DsG5f4ECqxYv6HeY0pYeLNEYQ838FNQYvPBm9dA9SqV2MsCqOklZSxbisMpBZeNaQ2HUZ0MYzgAwYR5ek3SFtlZBPEhxVULt-ktgtHJFwYZ3B0i8I4W80xVItzzpARFNKJg2DUlYJoHlphhjrA2aYpCEllgTJqz+uBv5r03s6f+ezRVZOoiCcB5S5IgmcBxG+eNnyKBaHXSYnhfT3JGby0W2DyCGWdZw5wV5JgGGGG4VxQilSWsjI4LKkjuJ1ALVsx5fLQ5wHtOWpU9tFBlkSX0naDUG01CZFyBQdhSkbhcrI7wQA */ id: "HYDRA", initial: "Disconnected", context: { @@ -244,8 +257,20 @@ export const hydra = setup({ } }, Committing: { + invoke: { + src: "commit", + input: ({ context }) => ({ + client: context.client, + request: context.request + }), + onError: { + target: "ReadyToCommit", + actions: "setError" + } + }, always: { target: "Done", + actions: "clearRequest", guard: "isCommitted" } }, @@ -317,9 +342,9 @@ class Client { }); } - async get(endpoint: string) { + async get(endpoint: string, signal?: AbortSignal) { try { - const { data, status } = await this._instance.get(endpoint); + const { data, status } = await this._instance.get(endpoint, { signal }); if (status === 200 || status == 202) return data; throw parseHttpError(data); } catch (error) { @@ -327,10 +352,10 @@ class Client { } } - async post(endpoint: string, payload: unknown, headers?: RawAxiosRequestHeaders) { + 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" } + headers: headers ?? { "Content-Type": "application/json" }, signal, }); if (status === 200 || status == 202) return data; throw parseHttpError(data); From 04df863638b937a59811a4df605a37652e63062f Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Fri, 11 Jul 2025 16:16:45 +0100 Subject: [PATCH 06/19] newTx, recoverUTxO and, decommitUTxO --- packages/mesh-hydra/src/hydra-machine.ts | 35 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index c88d9226b..c90c30af0 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -4,20 +4,32 @@ import { parseHttpError } from "./utils"; export const hydra = setup({ actions: { + newTx: ({ event }) => { + assertEvent(event, "NewTx") + sendTo("server", { type: "Send", data: { tag: event.type, transaction: event.tx } }) + }, + recoverUTxO: ({ event }) => { + assertEvent(event, "Recover") + sendTo("server", { type: "Send", data: { tag: event.type, recoverTxId: event.txHash } }) + }, + decommitUTxO: ({ event }) => { + assertEvent(event, "Decommit") + sendTo("server", { type: "Send", data: { tag: event.type, decommitTxId: event.tx } }) + }, initHead: () => { - sendTo("server", { type: "Send", data: { "tag": "Init" } }) + sendTo("server", { type: "Send", data: { tag: "Init" } }) }, abortHead: () => { - sendTo("server", { type: "Send", data: { "tag": "Abort" } }) + sendTo("server", { type: "Send", data: { tag: "Abort" } }) }, closeHead: () => { - sendTo("server", { type: "Send", data: { "tag": "Close" } }) + sendTo("server", { type: "Send", data: { tag: "Close" } }) }, contestHead: () => { - sendTo("server", { type: "Send", data: { "tag": "Contest" } }) + sendTo("server", { type: "Send", data: { tag: "Contest" } }) }, fanoutHead: () => { - sendTo("server", { type: "Send", data: { "tag": "Fanout" } }) + sendTo("server", { type: "Send", data: { tag: "Fanout" } }) }, closeConnection: ({ context }) => { if (context.connection?.readyState === WebSocket.OPEN) { @@ -155,14 +167,16 @@ export const hydra = setup({ | { type: "Disconnect", code: number } | { type: "Init" } | { type: "Commit", data: unknown } + | { type: "NewTx", tx: unknown } + | { type: "Recover", txHash: unknown } + | { type: "Decommit", tx: unknown } | { type: "Abort" } - | { type: "Close" } | { type: "Contest" } | { type: "Fanout" } | { type: "Close" } }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VNwcQy2VryUaqTqTMEIeRWXDtBxaWHacbuZGoxa0BgAWTgsHyMFJL3JlSpiDqHBcA30zmkTImdjZehUyk0jkMSk6amk8hmfhR8wu2LoEHoEtgUplLh4z1eCtA1JcTm+gNpk0BclkbLcvIazUM9nUWhN0yFNqxmXFkulYDYsndZIq70VNV9NhVLg4HDGMJUENB5iVkyUuDNal5ahchukk0TgWToVTzvTbHk2blucp3sQmi0uGG7ZGo15skrYfN9QX7VGKn+JYhnYW6Pt9EIJD3ss9ebHvU6uCsXmstNa7QXbJUMKvQ3NmkZ5rNO9tKYdAFENi2E95TPSQZERWxrCBcsxkRJ8X1kN95A-JQv2kH9u1FCBcAASQgXYwHoXDyGEECR3ID4EAcfpZBjCYOXvORdUrZQ5A4M17EMZ8NEwkV7TwgiiPIilKPzdwVH1QEPCcLQSwcaw2TjOkOkGDkjFUBw+L3SA8NIkR8l2AgAC9YjxbAACMthoESvXAhBES+KxBkRZ89UMeQ2W0SE3EkjoQS8CEVG0u1dJIt5DJMsz6FssDqTLesNyQoLfWLMNSzpNQJjcGFWmfewQr-PSIqM0zcRit0ylA0d7OmeolBLJQOF5Vs9HsdLaQbbKIXGOECqtYUdJw8KDNKszcAJIkzAAFVIZhjmOYQmFIBayO4KqKKoiFZFVQYOj+aYnAUNQvJ0KdW0kySONLLSBqTfiwv0ghIrKqA0VWmgRHK9ZNm2PZDhOM57qG4rRqi3F3sWz6zJuPJCmWYpYpq6kEUUOQm3nE15A5asei3SFnLkxoPJUFVCp7Yanpe8b5qhr68SRsTz19CEDQ4jQkMBTp0tYlVKxBTQYRQ8nsNwAB5PgwBWRhdkEDN1o9aqmfsv5+maBESxjKwETDRFFBxuSPErDpPBFgSJalmKFZzUSto4bGBjolqzRVM0w2xyd1HUTUvCjc0zd0mW5YdYI6FgGzreHW383afVTRQnQmpGc0w1R19VA8T94wDnCg9gBhGbtjoG20O8F15ORpDDewVzLON9A435-hz9JZfzh02EqxXNvzBFJxLDy-jkpCkNxpUa9fOjS8bjzZBbgAxfJyFIABXGgAAVBCECzCPoRfl7Xwv83NOkYVkJtn19fQq5rGpJJsI06jNDoIVuuYuwenD99Xjet4IHfhKR1PMjRAz9cDqCBK0X4fwPKeVvh0S8XszQmicI4f2d0P4g3nrEQyxF9JH3PHGVUDUdBZQ8NCMeNQ9YNG1m5C65otALxwbsK2Q5gHK2qE2SE7Z9CXxUNxBwcCejWESloBcrh5DmgmM1XwVpl4QDgOIc4G1o7nkZPqVQ592yNAcDtDi6VJzcg0PGRE9hrCYUPGQUKEAVF2WqPfK8a5G723aNGNkjR6weB2iCZsDDJJmzAuwqiD5lBjEvqoZqHIVBhm4uA4EagLTaDon4gJFAsL01sXFRA0Z+ijG4q4CJgJHy3wtMQv4rY9ZkKbKk8g+AoiZJAVQhqapibGkzk1aJJSsq2A3K2bGiInAWktO-Xc1iGkcPHByA0WjPDsz0SdW+RoGhoQmPzaMC5hnWkwdYwShFxlUT6NMs0szdHySUtoZQKygT2FLByOeGDRlFRGs9MauJ9niUmPrDyZidB1EkpQ3QdI5L3xauoWELdnnUwhpNYks1abCHeczFsqovCmm8n8zQXlxi4FpFuVo9cTRKAhVTV5b14XQzed3VR9lUo2DsG5f4ECqxYv6HeY0pYeLNEYQ838FNQYvPBm9dA9SqV2MsCqOklZSxbisMpBZeNaQ2HUZ0MYzgAwYR5ek3SFtlZBPEhxVULt-ktgtHJFwYZ3B0i8I4W80xVItzzpARFNKJg2DUlYJoHlphhjrA2aYpCEllgTJqz+uBv5r03s6f+ezRVZOoiCcB5S5IgmcBxG+eNnyKBaHXSYnhfT3JGby0W2DyCGWdZw5wV5JgGGGG4VxQilSWsjI4LKkjuJ1ALVsx5fLQ5wHtOWpU9tFBlkSX0naDUG01CZFyBQdhSkbhcrI7wQA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VWr9DiGdSuZqyYYqMEIeRWXAQvSGLTMhQ-ZGoxa0BgAWTgsHyMFJL3JlSpiDqHHqw1cSk6OhU7RcbL0WgGzg52g4dOaLiF8wu2LoEHoEtgUplLh4z1eCtA1Nkpq5HO19PhmmkbJcBhUAxUzL5hg1k3plsCWMy4sl0rAbFkrrJFXeipqLmauE8claSmG2kRIZUg1wCJUka8dScWgTC3RtvtqZl8izcpzlM9iE0Bq0HS09LsgK02hD0nk4YLMNq7QR89b1uTdsIJHbsvducHvU6uCsXmsdNa7VkrPMQ5hJ6Gc808iUc7U0nXSdCDAAohstnu8oHpIMiIrY1hAmMRiVrevT3t6ZZPhqr7yO+n4irauAAJIQLsYD0Fh5DCIB-bkB8CAOP047uKG7gcPOEJ6p4yjetI2gNr8rjoe2kDYbh+EkRSZF5u46i4BwfKDBoaimvRbLUeJ1h6C42qRjoFp+CiVpfqKEDYURIj5LsBAAF6xHi2AAEZbDQgkeiBCCIl8rgcqeEJ0jePTaIY4mAipCjPl43E2rxhFvEZpnmfQdnAdSKpKMomiIhyILqBoIYcG4RYIqoUxWDGGlzImGGhQZBARWZuLRS6ZRAQODnTIoCiDHU0yRm4GVZeoViTDo+VlsFm76eFxmVVAuAEkSZgACqkMwxzHMITCkAtxHcLVpHkRC14NJ4ZZAplTgqmy1g+ZGfxeKOVgQkog3fnpYWGaN5loqtNAiFV6ybNseyHCcZzaSVD1lRVL3zYt73mTceSFMsxQxfV1IIganRlnUkyvqOnmIM0PlSTGvzMiyhVacVPHAyNkW4q9EMfXiCPCYeBaaLgvIeI0L56J1-TXi+SimvzbF3bpuAAPJ8GAKyMLsgjputbp1YzDlOYo9HSJM9bTn8IaIv0-O-BqEzvp4KjC5h4uS9F8vZkJW30QuOXOCodL6O4IbyC+Rb0Y0rgeJlIxm7x0uy3awR0LAtnW32tt5u04ZLg4sh8t7wwhgiNjAoyQIIrIIIzJpwrk+kMuwAwDN2yp4l1GM+jsdtIb2A7Kkqbo9H+YHenB6XdpsDVCubXmyO1qhjiqGoKn2PIDcaFy48qC3gxAtWHe4AAYvk5CkAArjQAAKghCJZeH0Ovm87+XebqzzapSS0c9VtWtbVtY9a6I4H4F4DRen9ve8HwQR8BJR33IjRA74XDKBhEvOcKE2Lu09s7D2BhRL+1up-MmIU9Kr1iEZAiBkL6HmnPUa8Yw1AeG0DnNQOtc4nl0P8DwbFfjjxXtg8guCCEOTIQlbaucxgjF+DGE6+gixuG9JleQ6s+SyF8JpTeEA4DiHOBtGOh4Xw2CTq+fmKltT2AcBlA03JBjTgEcCHw6C0jbjIJg5R9lqj1nUe0fmIJ6LtF5GyRoCUPDMhBOOd8nR6xm2AiApW1R2iKGznYDy7RBjYxqIYRQOhW4STjPzAwgSKA6TpjY2KONRwNGBJE+k0SJjuPHrPNSFDtBaI-kVNsNpMToCiNk0BNQrDEO1FYTiYxhhT1gvOHy4w1JO0iaMDuzSQlDg9ieacbFMr1l5A4KhsFXYNFQvqewMDeQrxwnhcZ5FNAQI0bM7RCy9HLJGMoNZ+h572JfNskGz1cR7JEr1cSnQmjiKBG7WCugIHOxBKGVu1Z-j3MpmNCahJiSzXBsIZ5TM55vP0DCT5bguiwRGPUf5-MkUeF5CTQumDhpPSpuNGFkMnn9xUQ5Asx5kVSMTqjWQJ0QSsw4ACxcOgGGguJeCxpVA4XUrnP0XkIw+oSO9DoDKYwBjennvRCY14JgrwtkrYJW1Jg2EQpdIxZYQQhghD5NQL4-g8nrCC8xG57rFxDgKuKkwuRjGHICEYBY0U9Bbv0OsztzpxnsCwjev996OgAbsyltiwEsscZ0dW3SZJMtgi3GwRrXyJysFfGppM6lDVYUZW1YDnBQkRACcenQJH6tcLtSYS49oewzQSoaYc4C2jzTUb2RZtRsTkL7XOoIE2G0gQoP4-xvIQmVRLcguAqAAHcAAEEdChgBnS4FtwIOjKAhNqiSure3uqTgaeiIxHE0qcGY3wQA */ id: "HYDRA", initial: "Disconnected", context: { @@ -265,7 +279,6 @@ export const hydra = setup({ }), onError: { target: "ReadyToCommit", - actions: "setError" } }, always: { @@ -286,7 +299,11 @@ export const hydra = setup({ always: { target: "Closed", guard: "isClosed" - } + }, + initial: "new state 1", + states: { + "new state 1": {} + }, }, Closed: { on: { From dd480f4748180ff34be5811ff0c9c079523bbd72 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 12 Jul 2025 11:34:38 +0100 Subject: [PATCH 07/19] refactoring --- packages/mesh-hydra/src/hydra-machine.ts | 50 ++++------------------ packages/mesh-hydra/src/utils/http.ts | 53 ++++++++++++++++++++++++ packages/mesh-hydra/src/utils/index.ts | 1 + 3 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 packages/mesh-hydra/src/utils/http.ts diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index c90c30af0..988a46549 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -1,6 +1,5 @@ -import axios, { AxiosInstance, type RawAxiosRequestHeaders } from "axios"; import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; -import { parseHttpError } from "./utils"; +import { HTTPClient } from "./utils"; export const hydra = setup({ actions: { @@ -53,7 +52,7 @@ export const hydra = setup({ return { connection: event.connection } }), createClient: assign(({ context }) => { - return { client: new Client(context.baseURL) } + return { client: new HTTPClient(context.baseURL) } }), setError: assign(({ event }) => { assertEvent(event, "Error") @@ -138,7 +137,7 @@ export const hydra = setup({ return () => ws.close(); }), - commit: fromPromise(async ({ input, signal }) => { + commit: fromPromise(async ({ input, signal }) => { if (!input.client) { throw new Error("Client is not initialized"); } @@ -152,7 +151,7 @@ export const hydra = setup({ types: { context: {} as { baseURL: string, - client?: Client, + client?: HTTPClient, connection?: WebSocket, error?: unknown, headURL: string, @@ -176,7 +175,7 @@ export const hydra = setup({ | { type: "Close" } }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VWr9DiGdSuZqyYYqMEIeRWXAQvSGLTMhQ-ZGoxa0BgAWTgsHyMFJL3JlSpiDqHHqw1cSk6OhU7RcbL0WgGzg52g4dOaLiF8wu2LoEHoEtgUplLh4z1eCtA1Nkpq5HO19PhmmkbJcBhUAxUzL5hg1k3plsCWMy4sl0rAbFkrrJFXeipqLmauE8claSmG2kRIZUg1wCJUka8dScWgTC3RtvtqZl8izcpzlM9iE0Bq0HS09LsgK02hD0nk4YLMNq7QR89b1uTdsIJHbsvducHvU6uCsXmsdNa7VkrPMQ5hJ6Gc808iUc7U0nXSdCDAAohstnu8oHpIMiIrY1hAmMRiVrevT3t6ZZPhqr7yO+n4irauAAJIQLsYD0Fh5DCIB-bkB8CAOP047uKG7gcPOEJ6p4yjetI2gNr8rjoe2kDYbh+EkRSZF5u46i4BwfKDBoaimvRbLUeJ1h6C42qRjoFp+CiVpfqKEDYURIj5LsBAAF6xHi2AAEZbDQgkeiBCCIl8rgcqeEJ0jePTaIY4mAipCjPl43E2rxhFvEZpnmfQdnAdSKpKMomiIhyILqBoIYcG4RYIqoUxWDGGlzImGGhQZBARWZuLRS6ZRAQODnTIoCiDHU0yRm4GVZeoViTDo+VlsFm76eFxmVVAuAEkSZgACqkMwxzHMITCkAtxHcLVpHkRC14NJ4ZZAplTgqmy1g+ZGfxeKOVgQkog3fnpYWGaN5loqtNAiFV6ybNseyHCcZzaSVD1lRVL3zYt73mTceSFMsxQxfV1IIganRlnUkyvqOnmIM0PlSTGvzMiyhVacVPHAyNkW4q9EMfXiCPCYeBaaLgvIeI0L56J1-TXi+SimvzbF3bpuAAPJ8GAKyMLsgjputbp1YzDlOYo9HSJM9bTn8IaIv0-O-BqEzvp4KjC5h4uS9F8vZkJW30QuOXOCodL6O4IbyC+Rb0Y0rgeJlIxm7x0uy3awR0LAtnW32tt5u04ZLg4sh8t7wwhgiNjAoyQIIrIIIzJpwrk+kMuwAwDN2yp4l1GM+jsdtIb2A7Kkqbo9H+YHenB6XdpsDVCubXmyO1qhjiqGoKn2PIDcaFy48qC3gxAtWHe4AAYvk5CkAArjQAAKghCJZeH0Ovm87+XebqzzapSS0c9VtWtbVtY9a6I4H4F4DRen9ve8HwQR8BJR33IjRA74XDKBhEvOcKE2Lu09s7D2BhRL+1up-MmIU9Kr1iEZAiBkL6HmnPUa8Yw1AeG0DnNQOtc4nl0P8DwbFfjjxXtg8guCCEOTIQlbaucxgjF+DGE6+gixuG9JleQ6s+SyF8JpTeEA4DiHOBtGOh4Xw2CTq+fmKltT2AcBlA03JBjTgEcCHw6C0jbjIJg5R9lqj1nUe0fmIJ6LtF5GyRoCUPDMhBOOd8nR6xm2AiApW1R2iKGznYDy7RBjYxqIYRQOhW4STjPzAwgSKA6TpjY2KONRwNGBJE+k0SJjuPHrPNSFDtBaI-kVNsNpMToCiNk0BNQrDEO1FYTiYxhhT1gvOHy4w1JO0iaMDuzSQlDg9ieacbFMr1l5A4KhsFXYNFQvqewMDeQrxwnhcZ5FNAQI0bM7RCy9HLJGMoNZ+h572JfNskGz1cR7JEr1cSnQmjiKBG7WCugIHOxBKGVu1Z-j3MpmNCahJiSzXBsIZ5TM55vP0DCT5bguiwRGPUf5-MkUeF5CTQumDhpPSpuNGFkMnn9xUQ5Asx5kVSMTqjWQJ0QSsw4ACxcOgGGguJeCxpVA4XUrnP0XkIw+oSO9DoDKYwBjennvRCY14JgrwtkrYJW1Jg2EQpdIxZYQQhghD5NQL4-g8nrCC8xG57rFxDgKuKkwuRjGHICEYBY0U9Bbv0OsztzpxnsCwjev996OgAbsyltiwEsscZ0dW3SZJMtgi3GwRrXyJysFfGppM6lDVYUZW1YDnBQkRACcenQJH6tcLtSYS49oewzQSoaYc4C2jzTUb2RZtRsTkL7XOoIE2G0gQoP4-xvIQmVRLcguAqAAHcAAEEdChgBnS4FtwIOjKAhNqiSure3uqTgaeiIxHE0qcGY3wQA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VWr9DiGdSuZqyYYqMEIeRWXAQvSGLTMhQ-ZGoxa0BgAWTgsHyMFJL3JlSpiDqHHqw1cSk6OhU7RcbL0WgGzg52g4dOaLiF8wu2LoEHoEtgUplLh4z1eCtA1Nkpq5HO19PhmmkbJcBhUAxUzL5hg1k3plsCWMy4sl0rAbFkrrJFXeipqLmauE8claSmG2kRIZUg1wCJUka8dScWgTC3RtvtqZl8izcpzlM9iE0Bq0HS09LsgK02hD0nk4YLMNq7QR89b1uTdsIJHbsvducHvU6uCsXmsdNa7VkrPMQ5hJ6Gc808iUc7U0nXSdCDAAohstnu8oHpIMiIrY1hAmMRiVrevT3t6ZZPhqr7yO+n4irauAAJIQLsYD0Fh5DCIB-bkB8CAOP047uKG7gcPOEJ6p4yjetI2gNr8rjoe2kDYbh+EkRSZF5u46i4BwfKDBoaimvRbLUeJ1h6C42qRjoFp+CiVpfqKEDYURIj5LsBAAF6xHi2AAEZbDQgkeiBCCIl8rgcqeEJ0jePTaIY4mAipCjPl43E2rxhFvEZpnmfQdnAdSKpKMomiIhyILqBoIYcG4RYIqoUxWDGGlzImGGhQZBARWZuLRS6ZRAQODnTIoCiDHU0yRm4GVZeoViTDo+VlsFm76eFxmVVAuAEkSZgACqkMwxzHMITCkAtxHcLVpHkRC14NJ4ZZAplTgqmy1g+ZGfxeKOVgQkog3fnpYWGaN5loqtNAiFV6ybNseyHCcZzaSVD1lRVL3zYt73mTceSFMsxQxfV1IIganRlnUkyvqOnmIM0PlSTGvzMiyhVacVPHAyNkW4q9EMfXiCPCYeBaaLgvIeI0L56J1-TXi+SimvzbF3bpuAAPJ8GAKyMLsgjputbp1YzDlOYo9HSJM9bTn8IaIv0-O-BqEzvp4KjC5h4uS-QAByYAAO7TRIDNbaurMgq03q8h08jBrBHR6LWfMOPSxb-GbvEWysTsifRC45c4Kh0vo7ghvIL5FvRjSuB4mUjGHenS7LdrBHQsC2fL2ZCeR7ThkuDiyHyGfDCGCI2MCjJAgisggjMmnCuT6Qy7ADBR0zmXhpliK8q0HjbSG9ixypKm6PR-l5wPhfVb2+6I4gyMB0HqhqCp9jyHPGhckfKhL4MQLVmvABi+TkKQACuNAAAqCEIll4fQj-P2-EeDUE4njVFJFol8qzVlrNWaw9ZdCOA-L3QG-d-6vw-l-AgP8BLlz7JXPM74XDKBhLfOcKE2IpzTgnVOBhRI51usgsmIU9L31iEZAiBkgHVGnPUa8Yw1AeG0J3NQOsu4nl0P8DwbFfhHwfmw3Y0VcHbyVtUARCVtpdzGCMX4MYTr6CLG4b0mVvaTCRMiZ+EA4DiHOBtfBh4Xw2Hrq+fmKltT2AcBlA03J9BJS7kfV8n5txkGYbY+y1R6yOPaPzN2jZeRskaAlDwzIQTjnfJ0esZtgLKKrh0BowI7AeXaIMbGNRDCKB0HoaskwVRpSQUVNsNpMQYXMqE2KONRx5IEfoekRSJjxKPhfNSQjtAuLqaTBpmRMToCiK0neNQrC8O1FYTiYxhin1gvOHy4w1LxwKaMPOsyVFDlTieacbEx5uIcCI2CScGioWmGxFKmo144Twoc8imgiFOPOa43kVz5IjGUPcnxri1AvheSDZ6uJ3kiV6uJToTRjFAmTrBXQRCE7vnHKncYXFGETPusNJ6VNxqTWJLNcGwgYVM0vvC-QMIkVuC6LBEY9QMX8zpR4T2ELKZjRpsIOmVKHIFmPPSvkfiFA8JOiCVmHAQRuGcDoKR3KiW8umVQQV1I5z9F5CMPq3tvQ6AymMAY3or70QmNeCYa8I4aqVJMGwiFLqDGnMCUEvsboB0Jn8MpXd2jWoluQXA01RboFFramoR9FCOt5M6ssIIdZ2HhSMKJwqnA+DxRuAlBch4QHDaGSYXIxjDkBCMAsTKehL36HWBO504z2Afk-dBn9HRYLeQrTaBDpVRM6OrVZMlZBVmmJ6+EiJXwJzGX3ZhuBWHkCMuGxBUJEQAkjYCH2FaIREPnJMJce1U4TpQVO4ucBbR5ozkWbUbE5BZy7m6ithtiEKD+P8byEJfC+CAA */ id: "HYDRA", initial: "Disconnected", context: { @@ -294,15 +293,16 @@ export const hydra = setup({ }, Open: { on: { - Close: { actions: "closeHead" } + Close: { actions: "closeHead" }, + NewTx: { actions: "newTx" } }, always: { target: "Closed", guard: "isClosed" }, - initial: "new state 1", + initial: "TODO", states: { - "new state 1": {} + TODO: {} }, }, Closed: { @@ -351,35 +351,3 @@ export const hydra = setup({ }, } }); - -class Client { - 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 parseHttpError(data); - } catch (error) { - throw parseHttpError(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 parseHttpError(data); - } catch (error) { - throw parseHttpError(error); - } - } - - private readonly _instance: AxiosInstance; -} diff --git a/packages/mesh-hydra/src/utils/http.ts b/packages/mesh-hydra/src/utils/http.ts new file mode 100644 index 000000000..fb3f343e8 --- /dev/null +++ b/packages/mesh-hydra/src/utils/http.ts @@ -0,0 +1,53 @@ +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); + } + } + + 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 62a725b83..065247093 100644 --- a/packages/mesh-hydra/src/utils/index.ts +++ b/packages/mesh-hydra/src/utils/index.ts @@ -1 +1,2 @@ +export * from "./http"; export * from "./parse-http-error"; From d2ef27f40e7d6de8bb4525ea60e4388de9e01f38 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 12 Jul 2025 15:50:25 +0100 Subject: [PATCH 08/19] hydraActor --- packages/mesh-hydra/src/hydra-driver.ts | 48 ++++++++++++++++++++++++ packages/mesh-hydra/src/hydra-machine.ts | 13 +------ 2 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 packages/mesh-hydra/src/hydra-driver.ts diff --git a/packages/mesh-hydra/src/hydra-driver.ts b/packages/mesh-hydra/src/hydra-driver.ts new file mode 100644 index 000000000..548a37bbc --- /dev/null +++ b/packages/mesh-hydra/src/hydra-driver.ts @@ -0,0 +1,48 @@ +import { createActor } from "xstate"; +import { machine } from "./hydra-machine"; + +// Create an actor instance +const hydraActor = createActor(machine, { + id: "hydra-machine", +}); + +// Start the actor +hydraActor.start(); + +// Example: Connect to a Hydra Head server +hydraActor.send({ + type: "Connect", + baseURL: "http://localhost:4001", +}); + +// Example: Initialize a Head +hydraActor.send({ type: "Init" }); + +// Example: Commit UTxOs +hydraActor.send({ + type: "Commit", + data: {} +}); + +// Example: Send a new transaction +hydraActor.send({ + type: "NewTx", + tx: { /* your tx data */ } +}); + +// Example: Recover or decommit UTxO +hydraActor.send({ type: "Recover", txHash: "txhash123" }); +hydraActor.send({ type: "Decommit", tx: "txid456" }); + +// Example: Close the Head +hydraActor.send({ type: "Close" }); + +// Example: Contest and Fanout +hydraActor.send({ type: "Contest" }); +hydraActor.send({ type: "Fanout" }); + +// Optional: Subscribe to state changes +hydraActor.subscribe((snapshot) => { + console.log("Current state:", snapshot.value); + console.log("Current context:", snapshot.context); +}); diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/hydra-machine.ts index 988a46549..5ee0f3156 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/hydra-machine.ts @@ -1,7 +1,7 @@ import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; import { HTTPClient } from "./utils"; -export const hydra = setup({ +export const machine = setup({ actions: { newTx: ({ event }) => { assertEvent(event, "NewTx") @@ -336,17 +336,6 @@ export const hydra = setup({ } }, Contested: {}, - // TxInvalid: {}, - // SnapshotConfirmed: {}, - // SnapshotSideLoaded: {}, - // DecommitRequested: {}, - // DecommitApproved: {}, - // DecommitInvalid: {}, - // DecommitFinalized: {}, - // CommitRecorded: {}, - // CommitApproved: {}, - // CommitFinalized: {}, - // CommitRecovered: {}, } }, } From 8a40d8ce8e7abe151c9101461d3725cdb16b17b6 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sun, 27 Jul 2025 16:36:36 +0100 Subject: [PATCH 09/19] HydraController wip --- packages/mesh-hydra/src/example.ts | 27 +++++ packages/mesh-hydra/src/hydra-controller.ts | 112 ++++++++++++++++++++ packages/mesh-hydra/src/hydra-driver.ts | 48 --------- packages/mesh-hydra/src/utils/emitter.ts | 24 +++++ 4 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 packages/mesh-hydra/src/example.ts create mode 100644 packages/mesh-hydra/src/hydra-controller.ts delete mode 100644 packages/mesh-hydra/src/hydra-driver.ts create mode 100644 packages/mesh-hydra/src/utils/emitter.ts diff --git a/packages/mesh-hydra/src/example.ts b/packages/mesh-hydra/src/example.ts new file mode 100644 index 000000000..43b3f3e69 --- /dev/null +++ b/packages/mesh-hydra/src/example.ts @@ -0,0 +1,27 @@ +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.start(); +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/hydra-controller.ts b/packages/mesh-hydra/src/hydra-controller.ts new file mode 100644 index 000000000..91a1b5ef6 --- /dev/null +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -0,0 +1,112 @@ +import { ActorRefFrom, createActor, StateValue } from "xstate"; +import { machine } from "./hydra-machine"; +import { Emitter } from "./utils/emitter"; + +type ConnectOptions = { + baseURL: string; + address?: string; + snapshot?: boolean; + history?: boolean; +}; + +export type HydraStateName = "*" + | "Disconnected" + | "Connecting" + | "Connected.Idle" + | "Connected.Initializing.ReadyToCommit" + | "Connected.Open" + | "Connected.Closed" + | "Connected.Final" + +type ActorRef = ActorRefFrom; +type Snapshot = ReturnType; + +type Events = { + '*': (snapshot: Snapshot) => void; } & { + [K in HydraStateName]: (snapshot: Snapshot) => void; +}; + +export class HydraController { + private actor = createActor(machine); + private emitter = new Emitter(); + private _currentSnapshot?: Snapshot; + + constructor() { + this.actor.subscribe({ + next: (snapshot) => this.handleState(snapshot), + error: (err) => console.error("Hydra error:", err), + }); + } + + /** Connect to the Hydra head */ + connect(options: ConnectOptions) { + this.actor.send({ type: "Connect", ...options }); + } + + /** Protocol commands */ + init() { this.actor.send({ type: "Init" }); } + commit(data: unknown = {}) { this.actor.send({ type: "Commit", data }); } + newTx(tx: unknown) { this.actor.send({ type: "NewTx", tx }); } + recover(txHash: unknown) { this.actor.send({ type: "Recover", txHash }); } + decommit(tx: unknown) { this.actor.send({ type: "Decommit", tx }); } + close() { this.actor.send({ type: "Close" }); } + contest() { this.actor.send({ type: "Contest" }); } + fanout() { this.actor.send({ type: "Fanout" }); } + + 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); + }); + } + + start() { this.actor.start() } + + stop() { this.actor.stop() } + + 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/hydra-driver.ts b/packages/mesh-hydra/src/hydra-driver.ts deleted file mode 100644 index 548a37bbc..000000000 --- a/packages/mesh-hydra/src/hydra-driver.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createActor } from "xstate"; -import { machine } from "./hydra-machine"; - -// Create an actor instance -const hydraActor = createActor(machine, { - id: "hydra-machine", -}); - -// Start the actor -hydraActor.start(); - -// Example: Connect to a Hydra Head server -hydraActor.send({ - type: "Connect", - baseURL: "http://localhost:4001", -}); - -// Example: Initialize a Head -hydraActor.send({ type: "Init" }); - -// Example: Commit UTxOs -hydraActor.send({ - type: "Commit", - data: {} -}); - -// Example: Send a new transaction -hydraActor.send({ - type: "NewTx", - tx: { /* your tx data */ } -}); - -// Example: Recover or decommit UTxO -hydraActor.send({ type: "Recover", txHash: "txhash123" }); -hydraActor.send({ type: "Decommit", tx: "txid456" }); - -// Example: Close the Head -hydraActor.send({ type: "Close" }); - -// Example: Contest and Fanout -hydraActor.send({ type: "Contest" }); -hydraActor.send({ type: "Fanout" }); - -// Optional: Subscribe to state changes -hydraActor.subscribe((snapshot) => { - console.log("Current state:", snapshot.value); - console.log("Current context:", snapshot.context); -}); diff --git a/packages/mesh-hydra/src/utils/emitter.ts b/packages/mesh-hydra/src/utils/emitter.ts new file mode 100644 index 000000000..acbb27055 --- /dev/null +++ b/packages/mesh-hydra/src/utils/emitter.ts @@ -0,0 +1,24 @@ +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)); + } +} From 14de8103b22f7e72d39a7a4b9f92e9607b6c4204 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Fri, 1 Aug 2025 16:17:11 +0100 Subject: [PATCH 10/19] refactor --- packages/mesh-hydra/src/example.ts | 1 - packages/mesh-hydra/src/hydra-controller.ts | 14 ++++++++------ packages/mesh-hydra/src/utils/emitter.ts | 6 ++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/mesh-hydra/src/example.ts b/packages/mesh-hydra/src/example.ts index 43b3f3e69..e17fce18f 100644 --- a/packages/mesh-hydra/src/example.ts +++ b/packages/mesh-hydra/src/example.ts @@ -13,7 +13,6 @@ controller.on("Connected.Idle", () => { }); // Connect to the Hydra node -controller.start(); controller.connect({ baseURL: "http://localhost:4001", address: "addr_test1vp5cxztpc6hep9ds7fjgmle3l225tk8ske3rmwr9adu0m6qchmx5z", diff --git a/packages/mesh-hydra/src/hydra-controller.ts b/packages/mesh-hydra/src/hydra-controller.ts index 91a1b5ef6..2312426d1 100644 --- a/packages/mesh-hydra/src/hydra-controller.ts +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -9,7 +9,7 @@ type ConnectOptions = { history?: boolean; }; -export type HydraStateName = "*" +type HydraStateName = "*" | "Disconnected" | "Connecting" | "Connected.Idle" @@ -18,8 +18,7 @@ export type HydraStateName = "*" | "Connected.Closed" | "Connected.Final" -type ActorRef = ActorRefFrom; -type Snapshot = ReturnType; +type Snapshot = ReturnType['getSnapshot']>; type Events = { '*': (snapshot: Snapshot) => void; } & { @@ -36,6 +35,7 @@ export class HydraController { next: (snapshot) => this.handleState(snapshot), error: (err) => console.error("Hydra error:", err), }); + this.actor.start(); } /** Connect to the Hydra head */ @@ -91,9 +91,11 @@ export class HydraController { }); } - start() { this.actor.start() } - - stop() { this.actor.stop() } + stop() { + this.actor.stop(); + this.emitter.clear(); + this._currentSnapshot = undefined; + } get state() { return this._currentSnapshot?.value; diff --git a/packages/mesh-hydra/src/utils/emitter.ts b/packages/mesh-hydra/src/utils/emitter.ts index acbb27055..6d68e1b10 100644 --- a/packages/mesh-hydra/src/utils/emitter.ts +++ b/packages/mesh-hydra/src/utils/emitter.ts @@ -21,4 +21,10 @@ export class Emitter void>> { 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(); + }); + } } From 6f20ed51b9ffcbb8080152893072097d779b0b31 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Fri, 1 Aug 2025 17:07:43 +0100 Subject: [PATCH 11/19] update deps --- package-lock.json | 85 ++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87e8f1915..5b8452ec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16226,12 +16226,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", @@ -36176,7 +36170,7 @@ }, "packages/bitcoin": { "name": "@meshsdk/bitcoin", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", "bip174": "^3.0.0-rc.1", @@ -36463,7 +36457,7 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -36481,11 +36475,11 @@ }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/core": "1.9.0-beta.68" + "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/core": "1.9.0-beta.69" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36496,15 +36490,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/core-cst": "1.9.0-beta.68", - "@meshsdk/provider": "1.9.0-beta.68", - "@meshsdk/react": "1.9.0-beta.68", - "@meshsdk/transaction": "1.9.0-beta.68", - "@meshsdk/wallet": "1.9.0-beta.68" + "@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" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36515,10 +36509,10 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", + "@meshsdk/common": "1.9.0-beta.69", "@sidan-lab/whisky-js-browser": "^1.0.9", "@sidan-lab/whisky-js-nodejs": "^1.0.9", "@types/base32-encoding": "^1.0.2", @@ -36528,7 +36522,7 @@ }, "devDependencies": { "@meshsdk/configs": "*", - "@meshsdk/provider": "1.9.0-beta.68", + "@meshsdk/provider": "1.9.0-beta.69", "@types/json-bigint": "^1.0.4", "eslint": "^8.57.0", "ts-jest": "^29.1.4", @@ -36538,7 +36532,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "^0.45.5", @@ -36549,7 +36543,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.4", "@harmoniclabs/uplc": "1.2.4", - "@meshsdk/common": "1.9.0-beta.68", + "@meshsdk/common": "1.9.0-beta.69", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -36568,11 +36562,12 @@ }, "packages/mesh-hydra": { "name": "@meshsdk/hydra", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/core-cst": "1.9.0-beta.68", - "axios": "^1.7.2" + "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/core-cst": "1.9.0-beta.69", + "axios": "^1.7.2", + "xstate": "^5.19.4" }, "devDependencies": { "@meshsdk/configs": "*", @@ -36635,11 +36630,11 @@ }, "packages/mesh-provider": { "name": "@meshsdk/provider", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/core-cst": "1.9.0-beta.68", + "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/core-cst": "1.9.0-beta.69", "@utxorpc/sdk": "^0.6.7", "@utxorpc/spec": "^0.16.0", "axios": "^1.7.2", @@ -36655,14 +36650,14 @@ }, "packages/mesh-react": { "name": "@meshsdk/react", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { "@fabianbormann/cardano-peer-connect": "^1.2.18", - "@meshsdk/bitcoin": "1.9.0-beta.68", - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/transaction": "1.9.0-beta.68", - "@meshsdk/wallet": "1.9.0-beta.68", + "@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/web3-sdk": "0.0.50", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", @@ -36700,10 +36695,10 @@ }, "packages/mesh-svelte": { "name": "@meshsdk/svelte", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/core": "1.9.0-beta.68", + "@meshsdk/core": "1.9.0-beta.69", "bits-ui": "1.0.0-next.65" }, "devDependencies": { @@ -36729,14 +36724,14 @@ }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "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.68", - "@meshsdk/core-cst": "1.9.0-beta.68", + "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/core-cst": "1.9.0-beta.69", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -36749,12 +36744,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.68", - "@meshsdk/core-cst": "1.9.0-beta.68", - "@meshsdk/transaction": "1.9.0-beta.68", + "@meshsdk/common": "1.9.0-beta.69", + "@meshsdk/core-cst": "1.9.0-beta.69", + "@meshsdk/transaction": "1.9.0-beta.69", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -36767,7 +36762,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.68", + "version": "1.9.0-beta.69", "license": "Apache-2.0", "dependencies": { "@sidan-lab/cardano-bar": "^0.0.7", From 7a0377290171168f9951bd8d3b5b060d0a942b5c Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sun, 3 Aug 2025 11:56:10 +0100 Subject: [PATCH 12/19] mock websocket --- package-lock.json | 8 +-- packages/mesh-hydra/package.json | 2 +- .../mesh-hydra/src/mocks/MockWebSocket.ts | 63 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/mesh-hydra/src/mocks/MockWebSocket.ts diff --git a/package-lock.json b/package-lock.json index 5b8452ec0..059d327da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35974,9 +35974,9 @@ } }, "node_modules/xstate": { - "version": "5.19.4", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.19.4.tgz", - "integrity": "sha512-h1UMSYOB564NXqAI+VpXrxwaBdOJUh6LOStooQ+Rn/+gqJWtGBfjZn265BwFI8Mp4ZoOyoLDNf8X1yp3faBUZQ==", + "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", @@ -36567,7 +36567,7 @@ "@meshsdk/common": "1.9.0-beta.69", "@meshsdk/core-cst": "1.9.0-beta.69", "axios": "^1.7.2", - "xstate": "^5.19.4" + "xstate": "^5.20.1" }, "devDependencies": { "@meshsdk/configs": "*", diff --git a/packages/mesh-hydra/package.json b/packages/mesh-hydra/package.json index 581cddb44..243caa569 100644 --- a/packages/mesh-hydra/package.json +++ b/packages/mesh-hydra/package.json @@ -30,7 +30,7 @@ "@meshsdk/common": "1.9.0-beta.69", "@meshsdk/core-cst": "1.9.0-beta.69", "axios": "^1.7.2", - "xstate": "^5.19.4" + "xstate": "^5.20.1" }, "devDependencies": { "@meshsdk/configs": "*", diff --git a/packages/mesh-hydra/src/mocks/MockWebSocket.ts b/packages/mesh-hydra/src/mocks/MockWebSocket.ts new file mode 100644 index 000000000..048ca28f0 --- /dev/null +++ b/packages/mesh-hydra/src/mocks/MockWebSocket.ts @@ -0,0 +1,63 @@ +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: unknown[] = []; + public eventLog: { type: WebSocketEventType; data?: unknown }[] = []; + + constructor(public url: string) { + setTimeout(() => { + this.readyState = WebSocket.OPEN; + this.logEvent('open'); + this.onopen?.(new Event('open')); + }, 0); + } + + send(data: unknown) { + if (this.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.logEvent('message', data); + this.sentMessages.push(data); + } + + close(code = 1000, reason = 'Normal closure') { + this.readyState = WebSocket.CLOSED; + const event = new CloseEvent('close', { wasClean: true, code, reason }); + this.logEvent('close', { code, reason }); + this.onclose?.(event); + } + + // Test hooks + mockReceive(data: string | ArrayBuffer | Blob, delay = 0) { + setTimeout(() => { + this.logEvent('message', data); + this.onmessage?.(new MessageEvent('message', { data })); + }, delay); + } + + mockBinaryReceive(binary: ArrayBuffer | Uint8Array | Blob, delay = 0) { + this.mockReceive(binary, delay); + } + + mockError(errorData?: unknown) { + const err = new Event('error'); + this.logEvent('error', errorData ?? err); + this.onerror?.(err); + } + + mockClose(code = 1006, reason = 'Unexpected closure') { + 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 }); + } +} From 7cd1e53f0ecee28eb75600a7e74d1e14eb11039c Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 9 Aug 2025 23:14:41 +0100 Subject: [PATCH 13/19] more refactoring --- .../mesh-hydra/src/{ => examples}/example.ts | 2 +- .../mesh-hydra/src/examples/usage-example.ts | 37 ++ packages/mesh-hydra/src/hydra-controller.ts | 2 +- .../mesh-hydra/src/mocks/MockHTTPClient.ts | 9 + .../mesh-hydra/src/mocks/MockWebSocket.ts | 51 ++- .../hydra-machine-refactored.ts | 399 ++++++++++++++++++ .../{ => state-management}/hydra-machine.ts | 6 +- 7 files changed, 481 insertions(+), 25 deletions(-) rename packages/mesh-hydra/src/{ => examples}/example.ts (92%) create mode 100644 packages/mesh-hydra/src/examples/usage-example.ts create mode 100644 packages/mesh-hydra/src/mocks/MockHTTPClient.ts create mode 100644 packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts rename packages/mesh-hydra/src/{ => state-management}/hydra-machine.ts (98%) diff --git a/packages/mesh-hydra/src/example.ts b/packages/mesh-hydra/src/examples/example.ts similarity index 92% rename from packages/mesh-hydra/src/example.ts rename to packages/mesh-hydra/src/examples/example.ts index e17fce18f..a770e54a8 100644 --- a/packages/mesh-hydra/src/example.ts +++ b/packages/mesh-hydra/src/examples/example.ts @@ -1,4 +1,4 @@ -import { HydraController } from "./hydra-controller"; +import { HydraController } from "../hydra-controller"; const controller = new HydraController(); 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 000000000..4d30c4f1d --- /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 index 2312426d1..33836729c 100644 --- a/packages/mesh-hydra/src/hydra-controller.ts +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -1,5 +1,5 @@ import { ActorRefFrom, createActor, StateValue } from "xstate"; -import { machine } from "./hydra-machine"; +import { machine } from "./state-management/hydra-machine"; import { Emitter } from "./utils/emitter"; type ConnectOptions = { diff --git a/packages/mesh-hydra/src/mocks/MockHTTPClient.ts b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts new file mode 100644 index 000000000..b22de3c4d --- /dev/null +++ b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts @@ -0,0 +1,9 @@ +import { HTTPClient } from '../utils'; + +export class MockHttpClient extends HTTPClient { + constructor(baseURL: string) { + super(baseURL); + } + post = jest.fn(); + get = jest.fn(); +} diff --git a/packages/mesh-hydra/src/mocks/MockWebSocket.ts b/packages/mesh-hydra/src/mocks/MockWebSocket.ts index 048ca28f0..049aca313 100644 --- a/packages/mesh-hydra/src/mocks/MockWebSocket.ts +++ b/packages/mesh-hydra/src/mocks/MockWebSocket.ts @@ -10,39 +10,45 @@ export class MockWebSocket { public sentMessages: unknown[] = []; public eventLog: { type: WebSocketEventType; data?: unknown }[] = []; + // Mocked methods + send = jest.fn(); + close = jest.fn(); + constructor(public url: string) { - setTimeout(() => { + this.send.mockImplementation((data: unknown) => { + if (this.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.logEvent('message', data); + this.sentMessages.push(data); + }); + + this.close.mockImplementation((code = 1000, reason = 'Normal closure') => { + if (this.readyState === WebSocket.CLOSED) return; + this.readyState = WebSocket.CLOSED; + const event = new CloseEvent('close', { wasClean: true, code, reason }); + this.logEvent('close', { code, reason }); + this.onclose?.(event); + }); + + // Defer opening to allow event listeners to be attached + process.nextTick(() => { this.readyState = WebSocket.OPEN; this.logEvent('open'); this.onopen?.(new Event('open')); - }, 0); - } - - send(data: unknown) { - if (this.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket is not open'); - } - this.logEvent('message', data); - this.sentMessages.push(data); - } - - close(code = 1000, reason = 'Normal closure') { - this.readyState = WebSocket.CLOSED; - const event = new CloseEvent('close', { wasClean: true, code, reason }); - this.logEvent('close', { code, reason }); - this.onclose?.(event); + }); } // Test hooks - mockReceive(data: string | ArrayBuffer | Blob, delay = 0) { - setTimeout(() => { + mockReceive(data: string | ArrayBuffer | Blob | Uint8Array) { + process.nextTick(() => { this.logEvent('message', data); this.onmessage?.(new MessageEvent('message', { data })); - }, delay); + }); } - mockBinaryReceive(binary: ArrayBuffer | Uint8Array | Blob, delay = 0) { - this.mockReceive(binary, delay); + mockBinaryReceive(binary: ArrayBuffer | Uint8Array | Blob) { + this.mockReceive(binary); } mockError(errorData?: unknown) { @@ -52,6 +58,7 @@ export class MockWebSocket { } 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 })); diff --git a/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts b/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts new file mode 100644 index 000000000..c3695110e --- /dev/null +++ b/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts @@ -0,0 +1,399 @@ +import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; +import { HTTPClient } from "../utils"; + +// 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; +} + +// Context type +export interface HydraContext { + baseURL: string; + client?: HTTPClient; + connection?: WebSocket; + error?: unknown; + headURL: string; + request?: unknown; +} + +// Event types +export type HydraEvent = + | { type: "Connect"; baseURL: string; address?: string; snapshot?: boolean; history?: boolean } + | { type: "Ready"; connection: WebSocket } + | { type: "Send"; data: unknown } + | { type: "Message"; data: { [x: string]: unknown; tag: string } } + | { type: "Error"; data: unknown } + | { type: "Disconnect"; code: number } + | { type: "Init" } + | { type: "Commit"; data: unknown } + | { type: "NewTx"; tx: unknown } + | { type: "Recover"; txHash: unknown } + | { type: "Decommit"; tx: unknown } + | { type: "Abort" } + | { type: "Contest" } + | { type: "Fanout" } + | { type: "Close" }; + +// Factory function to create the machine with injected dependencies +export function createHydraMachine(config: HydraMachineConfig = {}) { + const { + webSocketFactory = new DefaultWebSocketFactory(), + httpClientFactory = new DefaultHTTPClientFactory(), + } = config; + + return setup({ + actions: { + newTx: ({ event }) => { + assertEvent(event, "NewTx"); + sendTo("server", { type: "Send", data: { tag: event.type, transaction: event.tx } }); + }, + recoverUTxO: ({ event }) => { + assertEvent(event, "Recover"); + sendTo("server", { type: "Send", data: { tag: event.type, recoverTxId: event.txHash } }); + }, + decommitUTxO: ({ event }) => { + assertEvent(event, "Decommit"); + sendTo("server", { type: "Send", data: { tag: event.type, decommitTxId: 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" } }); + }, + closeConnection: ({ context }) => { + if (context.connection?.readyState === WebSocket.OPEN) { + context.connection.close(1000, "Client disconnected"); + } + return { baseURL: "", headURL: "", connection: undefined, error: undefined }; + }, + setURL: assign(({ event }) => { + assertEvent(event, "Connect"); + const url = event.baseURL.replace("http", "ws"); + const history = `history=${event.history ? "yes" : "no"}`; + const snapshot = `snapshot-utxo=${event.snapshot ? "yes" : "no"}`; + const address = event.address ? `&address=${event.address}` : ""; + return { + baseURL: event.baseURL, + headURL: `${url}/?${history}&${snapshot}${address}`, + }; + }), + setConnection: assign(({ event }) => { + assertEvent(event, "Ready"); + return { connection: event.connection }; + }), + createClient: assign(({ context }) => { + return { client: httpClientFactory.create(context.baseURL) }; + }), + setError: assign(({ event }) => { + assertEvent(event, "Error"); + return { error: event.data }; + }), + setRequest: assign(({ event }) => { + assertEvent(event, ["Commit"]); + return { request: event.data }; + }), + clearRequest: assign(() => { + return { request: undefined }; + }), + }, + guards: { + isInitializing: ({ event }) => { + assertEvent(event, "Message"); + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Initializing"; + } + return event.data.tag === "HeadIsInitializing"; + }, + isAborted: ({ event }) => { + assertEvent(event, "Message"); + return event.data.tag === "HeadIsAborted"; + }, + isCommitted: ({ event }) => { + assertEvent(event, "Message"); + return event.data.tag === "Committed"; + }, + isOpen: ({ event }) => { + assertEvent(event, "Message"); + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Open"; + } + return event.data.tag === "HeadIsOpen"; + }, + isClosed: ({ event }) => { + assertEvent(event, "Message"); + if (event.data.tag === "Greetings") { + return event.data.headStatus === "Closed"; + } + return event.data.tag === "HeadIsClosed"; + }, + isContested: ({ event }) => { + assertEvent(event, "Message"); + return event.data.tag === "HeadIsContested"; + }, + isReadyToFanout: ({ event }) => { + assertEvent(event, "Message"); + if (event.data.tag === "Greetings") { + return event.data.headStatus === "FanoutPossible"; + } + return event.data.tag === "ReadyToFanout"; + }, + isFinalized: ({ event }) => { + assertEvent(event, "Message"); + return event.data.tag === "HeadIsFinalized"; + }, + }, + actors: { + 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) => { + sendBack({ type: "Message", data: JSON.parse(event.data) }); + }; + ws.onclose = (event) => { + sendBack({ type: "Disconnect", code: event.code }); + }; + + receive((event) => { + assertEvent(event, "Send"); + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event.data)); + else sendBack({ type: "Error", data: new Error("Connection is not open") }); + }); + + return () => ws.close(); + }), + commit: fromPromise( + 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; + return await client.post("/commit", request, undefined, signal); + } + ), + }, + types: { + context: {} as HydraContext, + events: {} as HydraEvent, + }, + }).createMachine({ + id: "HYDRA", + initial: "Disconnected", + context: { + baseURL: "", + headURL: "", + }, + states: { + Disconnected: { + on: { + Connect: { + target: "Connection", + actions: "setURL", + }, + }, + }, + Connection: { + invoke: { + src: "server", + input: ({ context }) => ({ + url: context.headURL, + }), + onDone: { + target: "Connected", + actions: "createClient", + }, + onError: "Disconnected", + }, + initial: "Connecting", + states: { + Connecting: { + on: { + Ready: { + target: "Done", + actions: "setConnection", + }, + }, + }, + Done: { type: "final" }, + }, + }, + Connected: { + on: { + Message: [ + { + target: ".Initializing", + guard: "isInitializing", + }, + { + target: ".Open", + guard: "isOpen", + }, + { + target: ".Closed", + guard: "isClosed", + }, + { + target: ".FanoutPossible", + guard: "isReadyToFanout", + }, + ], + Disconnect: { + target: "Disconnected", + actions: "closeConnection", + }, + Error: { actions: "setError" }, + }, + initial: "Idle", + states: { + Idle: { + on: { + Init: { actions: "initHead" }, + }, + always: { + target: "Initializing", + guard: "isInitializing", + }, + }, + Initializing: { + on: { + Abort: { actions: "abortHead" }, + }, + always: [ + { + target: "Open", + guard: "isOpen", + }, + { + target: "Final", + guard: "isAborted", + }, + ], + initial: "ReadyToCommit", + states: { + ReadyToCommit: { + on: { + Commit: { + target: "Committing", + actions: "setRequest", + }, + }, + }, + Committing: { + invoke: { + src: "commit", + input: ({ context }) => ({ + client: context.client, + request: context.request, + }), + onError: { + target: "ReadyToCommit", + }, + }, + always: { + target: "Done", + actions: "clearRequest", + guard: "isCommitted", + }, + }, + Done: { + type: "final", + }, + }, + }, + Open: { + on: { + Close: { actions: "closeHead" }, + NewTx: { actions: "newTx" }, + }, + always: { + target: "Closed", + guard: "isClosed", + }, + initial: "TODO", + states: { + TODO: {}, + }, + }, + Closed: { + on: { + Contest: { actions: "contestHead" }, + }, + always: [ + { + target: "Contested", + guard: "isContested", + }, + { + target: "FanoutPossible", + guard: "isReadyToFanout", + }, + ], + }, + FanoutPossible: { + on: { + Fanout: { actions: "fanoutHead" }, + }, + always: { + target: "Final", + guard: "isFinalized", + }, + }, + Final: { + on: { + Init: { actions: "initHead" }, + }, + always: { + target: "Initializing", + guard: "isInitializing", + }, + }, + Contested: {}, + }, + }, + }, + }); +} + +// Re-export the original machine for backward compatibility +export const machine = createHydraMachine(); diff --git a/packages/mesh-hydra/src/hydra-machine.ts b/packages/mesh-hydra/src/state-management/hydra-machine.ts similarity index 98% rename from packages/mesh-hydra/src/hydra-machine.ts rename to packages/mesh-hydra/src/state-management/hydra-machine.ts index 5ee0f3156..24cdfc8ba 100644 --- a/packages/mesh-hydra/src/hydra-machine.ts +++ b/packages/mesh-hydra/src/state-management/hydra-machine.ts @@ -1,6 +1,10 @@ import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; -import { HTTPClient } from "./utils"; +import { HTTPClient } from "../utils"; +// a reimplementation of the hydra protocol using xstate +// https://hydra.family/head-protocol/docs +// https://hydra.family/head-protocol/docs/dev +// https://hydra.family/head-protocol/api-reference export const machine = setup({ actions: { newTx: ({ event }) => { From c60c91c10354ea9877847739e69e27e2175de0e9 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 16 Aug 2025 08:57:01 +0100 Subject: [PATCH 14/19] update dependencies --- package-lock.json | 158 ++++++++++++++++------------------------------ 1 file changed, 53 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index e22b6fe44..d5e47e946 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", @@ -18630,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", @@ -22816,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": { @@ -24650,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": { @@ -26311,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" @@ -26775,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", @@ -26787,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" @@ -32481,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", @@ -32491,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", @@ -32507,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": { @@ -33104,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", @@ -35001,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", @@ -35185,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", @@ -36128,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", @@ -36141,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": { @@ -36383,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", @@ -36439,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", @@ -36670,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", @@ -36688,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": "*", @@ -36703,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": "*", @@ -36722,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", @@ -36735,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", @@ -36745,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", @@ -36756,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", @@ -36775,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": "*", @@ -36828,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", @@ -36862,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", @@ -36907,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": { @@ -36936,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": { @@ -36956,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": { @@ -36974,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", From 0987dde63bab165ce14990cd0706f0ee63d2670c Mon Sep 17 00:00:00 2001 From: lisicky Date: Tue, 12 Aug 2025 11:24:56 +0900 Subject: [PATCH 15/19] update hydra machine, add machine tests --- packages/mesh-hydra/src/hydra-controller.ts | 113 +- packages/mesh-hydra/src/hydra-machine.test.ts | 498 ++++++++ .../src/state-management/hydra-machine.ts | 1120 +++++++++++++---- 3 files changed, 1477 insertions(+), 254 deletions(-) create mode 100644 packages/mesh-hydra/src/hydra-machine.test.ts diff --git a/packages/mesh-hydra/src/hydra-controller.ts b/packages/mesh-hydra/src/hydra-controller.ts index 33836729c..d099303a1 100644 --- a/packages/mesh-hydra/src/hydra-controller.ts +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -1,6 +1,7 @@ import { ActorRefFrom, createActor, StateValue } from "xstate"; import { machine } from "./state-management/hydra-machine"; import { Emitter } from "./utils/emitter"; +import { HTTPClient } from "./utils"; type ConnectOptions = { baseURL: string; @@ -9,19 +10,21 @@ type ConnectOptions = { history?: boolean; }; -type HydraStateName = "*" +type HydraStateName = + | "*" | "Disconnected" | "Connecting" | "Connected.Idle" | "Connected.Initializing.ReadyToCommit" | "Connected.Open" | "Connected.Closed" - | "Connected.Final" + | "Connected.Final"; -type Snapshot = ReturnType['getSnapshot']>; +type Snapshot = ReturnType["getSnapshot"]>; type Events = { - '*': (snapshot: Snapshot) => void; } & { + "*": (snapshot: Snapshot) => void; +} & { [K in HydraStateName]: (snapshot: Snapshot) => void; }; @@ -29,6 +32,7 @@ export class HydraController { private actor = createActor(machine); private emitter = new Emitter(); private _currentSnapshot?: Snapshot; + private httpClient?: HTTPClient; constructor() { this.actor.subscribe({ @@ -41,20 +45,100 @@ export class HydraController { /** Connect to the Hydra head */ connect(options: ConnectOptions) { this.actor.send({ type: "Connect", ...options }); + this.httpClient = new HTTPClient(options.baseURL); } /** Protocol commands */ - init() { this.actor.send({ type: "Init" }); } - commit(data: unknown = {}) { this.actor.send({ type: "Commit", data }); } - newTx(tx: unknown) { this.actor.send({ type: "NewTx", tx }); } - recover(txHash: unknown) { this.actor.send({ type: "Recover", txHash }); } - decommit(tx: unknown) { this.actor.send({ type: "Decommit", tx }); } - close() { this.actor.send({ type: "Close" }); } - contest() { this.actor.send({ type: "Contest" }); } - fanout() { this.actor.send({ type: "Fanout" }); } + init() { + this.actor.send({ type: "Init" }); + } + commit(data: unknown = {}) { + this.actor.send({ type: "Commit", data }); + } + newTx(tx: string) { + this.actor.send({ type: "NewTx", tx }); + } + recover(txHash: string) { + this.actor.send({ type: "Recover", txHash }); + } + decommit(tx: string) { + this.actor.send({ type: "Decommit", tx }); + } + close() { + this.actor.send({ type: "Close" }); + } + contest() { + this.actor.send({ type: "Contest" }); + } + fanout() { + this.actor.send({ type: "Fanout" }); + } + sideLoadSnapshot(snapshot: unknown) { + 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: unknown) { + if (!this.httpClient) throw new Error("Not connected"); + return await this.httpClient.post("/cardano-transaction", tx); + } + + async submitL2Transaction(tx: unknown) { + 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; + 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); @@ -95,6 +179,7 @@ export class HydraController { this.actor.stop(); this.emitter.clear(); this._currentSnapshot = undefined; + this.httpClient = undefined; } get state() { @@ -109,6 +194,6 @@ export class HydraController { 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) + .map(([k, v]) => (v ? `${k}.${_flattenState(v)}` : k)) .join(".") as HydraStateName; } diff --git a/packages/mesh-hydra/src/hydra-machine.test.ts b/packages/mesh-hydra/src/hydra-machine.test.ts new file mode 100644 index 000000000..e0a4e5f74 --- /dev/null +++ b/packages/mesh-hydra/src/hydra-machine.test.ts @@ -0,0 +1,498 @@ +import { createActor } from "xstate"; + +// Lightweight HTTP client stub injected via module mock +class HTTPClientStub { + public static instances: HTTPClientStub[] = []; + public static nextPostErrors: Error[] = []; + public static postCalls: Array<{ endpoint: string; payload: unknown }> = []; + public static nextPostResponses: unknown[] = []; + + constructor(public baseURL: string) { + HTTPClientStub.instances.push(this); + } + + async post(endpoint: string, payload: unknown) { + HTTPClientStub.postCalls.push({ endpoint, payload }); + if (HTTPClientStub.nextPostErrors.length > 0) { + throw HTTPClientStub.nextPostErrors.shift(); + } + if (HTTPClientStub.nextPostResponses.length > 0) { + return HTTPClientStub.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" }; + } +} + +// Mock the utils module to use our HTTPClient stub +jest.mock("./utils", () => ({ + HTTPClient: HTTPClientStub, +})); + +// Import after mocks are set up +import { machine } from "./hydra-machine"; + +// Minimal WebSocket stub compatible with the machine's server actor +class TestWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + public readyState = TestWebSocket.CONNECTING; + public onopen: ((ev: any) => void) | null = null; + public onmessage: ((ev: { data: any }) => void) | null = null; + public onclose: ((ev: { code: number; reason?: string }) => void) | null = + null; + public onerror: ((ev: any) => void) | null = null; + public sentMessages: string[] = []; + + constructor(public url: string) { + // Simulate async open + setImmediate(() => { + this.readyState = TestWebSocket.OPEN; + this.onopen?.({}); + }); + } + + send(data: string) { + if (this.readyState !== TestWebSocket.OPEN) { + throw new Error("WebSocket is not open"); + } + this.sentMessages.push(data); + } + + close(code = 1000, reason = "") { + this.readyState = TestWebSocket.CLOSED; + this.onclose?.({ code, reason }); + } + + // Test helper to simulate an incoming JSON message + mockReceive(obj: unknown) { + const data = typeof obj === "string" ? obj : JSON.stringify(obj); + this.onmessage?.({ data }); + } +} + +// Attach stub to globalThis before tests run +(globalThis as any).WebSocket = TestWebSocket as any; + +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: TestWebSocket; + + beforeEach(() => { + HTTPClientStub.instances = []; + HTTPClientStub.nextPostErrors = []; + HTTPClientStub.postCalls = []; + HTTPClientStub.nextPostResponses = []; + 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 TestWebSocket; + + // 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 testActor = createActor(machine); + testActor.start(); + + testActor.send({ type: "Connect", baseURL: "http://localhost:4001" }); + await flush(); + + const testWs = testActor.getSnapshot().context + .connection as unknown as TestWebSocket; + 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 TestWebSocket; + 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(HTTPClientStub.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 + HTTPClientStub.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 TestWebSocket; + 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 TestWebSocket; + 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 TestWebSocket; + + // 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 index 24cdfc8ba..36171dd8c 100644 --- a/packages/mesh-hydra/src/state-management/hydra-machine.ts +++ b/packages/mesh-hydra/src/state-management/hydra-machine.ts @@ -1,346 +1,986 @@ -import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; +import { + AnyEventObject, + assertEvent, + assign, + fromCallback, + fromPromise, + sendTo, + setup, +} from "xstate"; import { HTTPClient } from "../utils"; -// a reimplementation of the hydra protocol using xstate -// https://hydra.family/head-protocol/docs -// https://hydra.family/head-protocol/docs/dev -// https://hydra.family/head-protocol/api-reference +/** ===== 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; + }; + export const machine = setup({ actions: { - newTx: ({ event }) => { - assertEvent(event, "NewTx") - sendTo("server", { type: "Send", data: { tag: event.type, transaction: event.tx } }) - }, - recoverUTxO: ({ event }) => { - assertEvent(event, "Recover") - sendTo("server", { type: "Send", data: { tag: event.type, recoverTxId: event.txHash } }) - }, - decommitUTxO: ({ event }) => { - assertEvent(event, "Decommit") - sendTo("server", { type: "Send", data: { tag: event.type, decommitTxId: 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" } }) - }, - closeConnection: ({ context }) => { + /** === 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 === WebSocket.OPEN) { context.connection.close(1000, "Client disconnected"); } - return { baseURL: "", headURL: "", connection: undefined, error: undefined }; - }, + 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"); + 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=${event.address}` : ""; + 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 }) => { - return { client: new HTTPClient(context.baseURL) } + assertEvent(event, "Ready"); + return { connection: event.connection }; }), + createClient: assign(({ context }) => ({ + client: new HTTPClient(context.baseURL), + })), setError: assign(({ event }) => { - assertEvent(event, "Error") - return { error: event.data } + 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 } + assertEvent(event, "Commit"); + return { request: event.data }; }), - clearRequest: assign(() => { - return { request: undefined } + 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") - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Initializing"; - } - return event.data.tag === "HeadIsInitializing"; + 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.tag === "HeadIsAborted"; + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "HeadIsAborted"; }, isCommitted: ({ event }) => { - assertEvent(event, "Message") - return event.data.tag === "Committed"; + 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") - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Open"; - } - return event.data.tag === "HeadIsOpen"; + 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") - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Closed"; - } - return event.data.tag === "HeadIsClosed"; + 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.tag === "HeadIsContested"; + assertEvent(event, "Message"); + return (event.data as HydraServerOutput).tag === "HeadIsContested"; }, isReadyToFanout: ({ event }) => { - assertEvent(event, "Message") - if (event.data.tag === "Greetings") { - return event.data.headStatus === "FanoutPossible"; - } - return event.data.tag === "ReadyToFanout"; + 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.tag === "HeadIsFinalized"; - } + 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: { - server: fromCallback(({ sendBack, receive, input }) => { - const ws = new WebSocket(input.url); + /** Long-lived WS actor */ + server: fromCallback( + ({ sendBack, receive, input }) => { + const ws = new WebSocket(input.url); - ws.onopen = () => { - sendBack({ type: "Ready", connection: ws }) - }; - ws.onerror = (error) => { - sendBack({ type: "Error", data: error }); - }; - ws.onmessage = (event) => { - sendBack({ type: "Message", data: JSON.parse(event.data) }); - }; - ws.onclose = (event) => { - sendBack({ type: "Disconnect", code: event.code }); - }; + 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 === WebSocket.OPEN) ws.send(JSON.stringify(event.data)); - else sendBack({ type: "Error", data: new Error("Connection is not open") }); - }); + receive((event) => { + assertEvent(event, "Send"); + if (ws.readyState === WebSocket.OPEN) + ws.send(JSON.stringify(event.data)); + else + sendBack({ + type: "Error", + data: new Error("Connection is not open"), + }); + }); - return () => ws.close(); - }), - commit: fromPromise(async ({ input, signal }) => { - if (!input.client) { - throw new Error("Client is not initialized"); - } - if (!input.request) { - throw new Error("Request is not provided"); - } + 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; - return await client.post("/commit", request, undefined, signal); - }) + 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?: unknown, - headURL: string, - request?: unknown, + 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: { [x: string]: unknown, tag: string } } - | { type: "Error", data: unknown } - | { type: "Disconnect", code: number } + | { + 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: unknown } - | { type: "Recover", txHash: unknown } - | { type: "Decommit", tx: unknown } + | { 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({ - /** @xstate-layout N4IgpgJg5mDOIC5QAkCaARASgQQHToEtYBjAewDtyxiAXSAYgGEKraBtABgF1FQAHUrAI0CFXiAAeiAOwAmAMy4AHABYArCqUA2adK0c5ATgA0IAJ6JZStbi1a1aw4ekBGF1pXzDAX2+m0WHjMlNQiFPQQFGC4BOQAbqQA1tEBOLjBrGHkCLEJxACGWZxcxeICQlniUghuLrK48gaGjhyqhi7yWqYWCJocuNKtLkpKsoYcKnbSar7+GGkZoaLk9GAATmuka7h8ADaFAGZbALa4qUEsSxQ58aQFRdylSCDlwstVMnUDE0qGsrpaeQuNRKbqWFzSWyDaTOVTTNSyRGzEDndKXWjLNEhDHkKD0TBgfIQMxPfiCN5iZ7VWr9DiGdSuZqyYYqMEIeRWXAQvSGLTMhQ-ZGoxa0BgAWTgsHyMFJL3JlSpiDqHHqw1cSk6OhU7RcbL0WgGzg52g4dOaLiF8wu2LoEHoEtgUplLh4z1eCtA1Nkpq5HO19PhmmkbJcBhUAxUzL5hg1k3plsCWMy4sl0rAbFkrrJFXeipqLmauE8claSmG2kRIZUg1wCJUka8dScWgTC3RtvtqZl8izcpzlM9iE0Bq0HS09LsgK02hD0nk4YLMNq7QR89b1uTdsIJHbsvducHvU6uCsXmsdNa7VkrPMQ5hJ6Gc808iUc7U0nXSdCDAAohstnu8oHpIMiIrY1hAmMRiVrevT3t6ZZPhqr7yO+n4irauAAJIQLsYD0Fh5DCIB-bkB8CAOP047uKG7gcPOEJ6p4yjetI2gNr8rjoe2kDYbh+EkRSZF5u46i4BwfKDBoaimvRbLUeJ1h6C42qRjoFp+CiVpfqKEDYURIj5LsBAAF6xHi2AAEZbDQgkeiBCCIl8rgcqeEJ0jePTaIY4mAipCjPl43E2rxhFvEZpnmfQdnAdSKpKMomiIhyILqBoIYcG4RYIqoUxWDGGlzImGGhQZBARWZuLRS6ZRAQODnTIoCiDHU0yRm4GVZeoViTDo+VlsFm76eFxmVVAuAEkSZgACqkMwxzHMITCkAtxHcLVpHkRC14NJ4ZZAplTgqmy1g+ZGfxeKOVgQkog3fnpYWGaN5loqtNAiFV6ybNseyHCcZzaSVD1lRVL3zYt73mTceSFMsxQxfV1IIganRlnUkyvqOnmIM0PlSTGvzMiyhVacVPHAyNkW4q9EMfXiCPCYeBaaLgvIeI0L56J1-TXi+SimvzbF3bpuAAPJ8GAKyMLsgjputbp1YzDlOYo9HSJM9bTn8IaIv0-O-BqEzvp4KjC5h4uS-QAByYAAO7TRIDNbaurMgq03q8h08jBrBHR6LWfMOPSxb-GbvEWysTsifRC45c4Kh0vo7ghvIL5FvRjSuB4mUjGHenS7LdrBHQsC2fL2ZCeR7ThkuDiyHyGfDCGCI2MCjJAgisggjMmnCuT6Qy7ADBR0zmXhpliK8q0HjbSG9ixypKm6PR-l5wPhfVb2+6I4gyMB0HqhqCp9jyHPGhckfKhL4MQLVmvABi+TkKQACuNAAAqCEIll4fQj-P2-EeDUE4njVFJFol8qzVlrNWaw9ZdCOA-L3QG-d-6vw-l-AgP8BLlz7JXPM74XDKBhLfOcKE2IpzTgnVOBhRI51usgsmIU9L31iEZAiBkgHVGnPUa8Yw1AeG0J3NQOsu4nl0P8DwbFfhHwfmw3Y0VcHbyVtUARCVtpdzGCMX4MYTr6CLG4b0mVvaTCRMiZ+EA4DiHOBtfBh4Xw2Hrq+fmKltT2AcBlA03J9BJS7kfV8n5txkGYbY+y1R6yOPaPzN2jZeRskaAlDwzIQTjnfJ0esZtgLKKrh0BowI7AeXaIMbGNRDCKB0HoaskwVRpSQUVNsNpMQYXMqE2KONRx5IEfoekRSJjxKPhfNSQjtAuLqaTBpmRMToCiK0neNQrC8O1FYTiYxhin1gvOHy4w1LxwKaMPOsyVFDlTieacbEx5uIcCI2CScGioWmGxFKmo144Twoc8imgiFOPOa43kVz5IjGUPcnxri1AvheSDZ6uJ3kiV6uJToTRjFAmTrBXQRCE7vnHKncYXFGETPusNJ6VNxqTWJLNcGwgYVM0vvC-QMIkVuC6LBEY9QMX8zpR4T2ELKZjRpsIOmVKHIFmPPSvkfiFA8JOiCVmHAQRuGcDoKR3KiW8umVQQV1I5z9F5CMPq3tvQ6AymMAY3or70QmNeCYa8I4aqVJMGwiFLqDGnMCUEvsboB0Jn8MpXd2jWoluQXA01RboFFramoR9FCOt5M6ssIIdZ2HhSMKJwqnA+DxRuAlBch4QHDaGSYXIxjDkBCMAsTKehL36HWBO504z2Afk-dBn9HRYLeQrTaBDpVRM6OrVZMlZBVmmJ6+EiJXwJzGX3ZhuBWHkCMuGxBUJEQAkjYCH2FaIREPnJMJce1U4TpQVO4ucBbR5ozkWbUbE5BZy7m6ithtiEKD+P8byEJfC+CAA */ id: "HYDRA", initial: "Disconnected", context: { baseURL: "", headURL: "", + submitPath: "/cardano-transaction", }, + states: { Disconnected: { on: { - Connect: { - target: "Connection", - actions: "setURL" - } - } + Connect: { target: "Connected", actions: "setURL" }, + }, }, - Connection: { + + Connected: { invoke: { + id: "server", src: "server", - input: ({ context }) => ({ - url: context.headURL, - }), - onDone: { - target: "Connected", - actions: "createClient" - }, - onError: "Disconnected" + input: ({ context }) => ({ url: context.headURL }), }, - initial: "Connecting", + + 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: { - Connecting: { + Handshake: { on: { Ready: { - target: "Done", - actions: "setConnection" - } - } - }, - Done: { type: "final" } - } - }, - Connected: { - on: { - Message: [{ - target: ".Initializing", - guard: "isInitializing", - }, { - target: ".Open", - guard: "isOpen", - }, { - target: ".Closed", - guard: "isClosed", - }, { - target: ".FanoutPossible", - guard: "isReadyToFanout", - }], - Disconnect: { - target: "Disconnected", - actions: "closeConnection" + // 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" }, + ], + }, }, - Error: { actions: "setError" } - }, - initial: "Idle", - states: { - Idle: { + + NoHead: { on: { - Init: { actions: "initHead" } + Init: { actions: ["clearError", "initHead"] }, }, - always: { - target: "Initializing", - guard: "isInitializing" - } }, + Initializing: { on: { - Abort: { actions: "abortHead" } + Abort: { actions: ["clearError", "abortHead"] }, + // Recover and Decommit are only available in Open state + Message: [ + // Handle when other parties commit + { guard: "isCommitted", actions: [] }, + ], }, - always: [{ - target: "Open", - guard: "isOpen" - }, { - target: "Final", - guard: "isAborted", - }], - initial: "ReadyToCommit", + initial: "Waiting", states: { - ReadyToCommit: { + Waiting: { + // Waiting for user to commit or for head to open on: { Commit: { - target: "Committing", - actions: "setRequest" - } - } + target: + "#HYDRA.Connected.Initializing.Depositing.RequestDraft", + actions: ["clearError", "setRequest"], + }, + }, }, - Committing: { - invoke: { - src: "commit", - input: ({ context }) => ({ - client: context.client, - request: context.request - }), - onError: { - target: "ReadyToCommit", - } + 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", }, - always: { - target: "Done", - actions: "clearRequest", - guard: "isCommitted" - } }, - Done: { - type: "final" - } }, }, + Open: { on: { - Close: { actions: "closeHead" }, - NewTx: { actions: "newTx" } - }, - always: { - target: "Closed", - guard: "isClosed" + 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: "TODO", + initial: "Active", states: { - TODO: {} + 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: "contestHead" } + Contest: { actions: ["clearError", "contestHead"] }, + Message: [ + { + guard: "isContested", + // Stay in Closed state, just update internal state + actions: [], + }, + ], }, - always: [{ - target: "Contested", - guard: "isContested" - }, { - target: "FanoutPossible", - guard: "isReadyToFanout" - }] }, + FanoutPossible: { on: { - Fanout: { actions: "fanoutHead" } + Fanout: { actions: ["clearError", "fanoutHead"] }, }, - always: { - target: "Final", - guard: "isFinalized" - } }, + Final: { on: { - Init: { actions: "initHead" } + Init: { actions: ["clearError", "initHead"] }, }, - always: { - target: "Initializing", - guard: "isInitializing" - } }, - Contested: {}, - } + }, }, - } + }, }); From 42bfb8837d361e4f5696efab5b7683a21474a980 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 16 Aug 2025 09:00:56 +0100 Subject: [PATCH 16/19] add missing delete --- packages/mesh-hydra/src/utils/http.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/mesh-hydra/src/utils/http.ts b/packages/mesh-hydra/src/utils/http.ts index fb3f343e8..838c3db70 100644 --- a/packages/mesh-hydra/src/utils/http.ts +++ b/packages/mesh-hydra/src/utils/http.ts @@ -29,6 +29,16 @@ export class HTTPClient { } } + 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; } From bb5382c918fe5539902794077967b8e8dceee8b5 Mon Sep 17 00:00:00 2001 From: Abdelkrim Dib Date: Sat, 16 Aug 2025 14:06:32 +0100 Subject: [PATCH 17/19] merge changes --- .../mesh-hydra/src/mocks/MockHTTPClient.ts | 44 +- .../mesh-hydra/src/mocks/MockWebSocket.ts | 48 +- .../hydra-machine-refactored.ts | 399 ----- .../hydra-machine.test.ts | 135 +- .../src/state-management/hydra-machine.ts | 1476 +++++++++-------- .../mesh-hydra/src/state-management/index.ts | 0 6 files changed, 859 insertions(+), 1243 deletions(-) delete mode 100644 packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts rename packages/mesh-hydra/src/{ => state-management}/hydra-machine.test.ts (80%) create mode 100644 packages/mesh-hydra/src/state-management/index.ts diff --git a/packages/mesh-hydra/src/mocks/MockHTTPClient.ts b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts index b22de3c4d..4427eafed 100644 --- a/packages/mesh-hydra/src/mocks/MockHTTPClient.ts +++ b/packages/mesh-hydra/src/mocks/MockHTTPClient.ts @@ -1,9 +1,47 @@ -import { HTTPClient } from '../utils'; +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 = []; } - post = jest.fn(); - get = jest.fn(); } diff --git a/packages/mesh-hydra/src/mocks/MockWebSocket.ts b/packages/mesh-hydra/src/mocks/MockWebSocket.ts index 049aca313..ea85a7e8b 100644 --- a/packages/mesh-hydra/src/mocks/MockWebSocket.ts +++ b/packages/mesh-hydra/src/mocks/MockWebSocket.ts @@ -7,48 +7,34 @@ export class MockWebSocket { public onclose: ((event: CloseEvent) => void) | null = null; public onerror: ((event: Event) => void) | null = null; - public sentMessages: unknown[] = []; + public sentMessages: string[] = []; public eventLog: { type: WebSocketEventType; data?: unknown }[] = []; - // Mocked methods - send = jest.fn(); - close = jest.fn(); constructor(public url: string) { - this.send.mockImplementation((data: unknown) => { - if (this.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket is not open'); - } - this.logEvent('message', data); - this.sentMessages.push(data); - }); - - this.close.mockImplementation((code = 1000, reason = 'Normal closure') => { - if (this.readyState === WebSocket.CLOSED) return; - this.readyState = WebSocket.CLOSED; - const event = new CloseEvent('close', { wasClean: true, code, reason }); - this.logEvent('close', { code, reason }); - this.onclose?.(event); - }); - - // Defer opening to allow event listeners to be attached - process.nextTick(() => { + // Simulate async open + setImmediate(() => { this.readyState = WebSocket.OPEN; - this.logEvent('open'); this.onopen?.(new Event('open')); }); } - // Test hooks - mockReceive(data: string | ArrayBuffer | Blob | Uint8Array) { - process.nextTick(() => { - this.logEvent('message', data); - this.onmessage?.(new MessageEvent('message', { data })); - }); + 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 })); } - mockBinaryReceive(binary: ArrayBuffer | Uint8Array | Blob) { - this.mockReceive(binary); + // 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) { diff --git a/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts b/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts deleted file mode 100644 index c3695110e..000000000 --- a/packages/mesh-hydra/src/state-management/hydra-machine-refactored.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { AnyEventObject, assertEvent, assign, fromCallback, fromPromise, sendTo, setup } from "xstate"; -import { HTTPClient } from "../utils"; - -// 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; -} - -// Context type -export interface HydraContext { - baseURL: string; - client?: HTTPClient; - connection?: WebSocket; - error?: unknown; - headURL: string; - request?: unknown; -} - -// Event types -export type HydraEvent = - | { type: "Connect"; baseURL: string; address?: string; snapshot?: boolean; history?: boolean } - | { type: "Ready"; connection: WebSocket } - | { type: "Send"; data: unknown } - | { type: "Message"; data: { [x: string]: unknown; tag: string } } - | { type: "Error"; data: unknown } - | { type: "Disconnect"; code: number } - | { type: "Init" } - | { type: "Commit"; data: unknown } - | { type: "NewTx"; tx: unknown } - | { type: "Recover"; txHash: unknown } - | { type: "Decommit"; tx: unknown } - | { type: "Abort" } - | { type: "Contest" } - | { type: "Fanout" } - | { type: "Close" }; - -// Factory function to create the machine with injected dependencies -export function createHydraMachine(config: HydraMachineConfig = {}) { - const { - webSocketFactory = new DefaultWebSocketFactory(), - httpClientFactory = new DefaultHTTPClientFactory(), - } = config; - - return setup({ - actions: { - newTx: ({ event }) => { - assertEvent(event, "NewTx"); - sendTo("server", { type: "Send", data: { tag: event.type, transaction: event.tx } }); - }, - recoverUTxO: ({ event }) => { - assertEvent(event, "Recover"); - sendTo("server", { type: "Send", data: { tag: event.type, recoverTxId: event.txHash } }); - }, - decommitUTxO: ({ event }) => { - assertEvent(event, "Decommit"); - sendTo("server", { type: "Send", data: { tag: event.type, decommitTxId: 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" } }); - }, - closeConnection: ({ context }) => { - if (context.connection?.readyState === WebSocket.OPEN) { - context.connection.close(1000, "Client disconnected"); - } - return { baseURL: "", headURL: "", connection: undefined, error: undefined }; - }, - setURL: assign(({ event }) => { - assertEvent(event, "Connect"); - const url = event.baseURL.replace("http", "ws"); - const history = `history=${event.history ? "yes" : "no"}`; - const snapshot = `snapshot-utxo=${event.snapshot ? "yes" : "no"}`; - const address = event.address ? `&address=${event.address}` : ""; - return { - baseURL: event.baseURL, - headURL: `${url}/?${history}&${snapshot}${address}`, - }; - }), - setConnection: assign(({ event }) => { - assertEvent(event, "Ready"); - return { connection: event.connection }; - }), - createClient: assign(({ context }) => { - return { client: httpClientFactory.create(context.baseURL) }; - }), - setError: assign(({ event }) => { - assertEvent(event, "Error"); - return { error: event.data }; - }), - setRequest: assign(({ event }) => { - assertEvent(event, ["Commit"]); - return { request: event.data }; - }), - clearRequest: assign(() => { - return { request: undefined }; - }), - }, - guards: { - isInitializing: ({ event }) => { - assertEvent(event, "Message"); - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Initializing"; - } - return event.data.tag === "HeadIsInitializing"; - }, - isAborted: ({ event }) => { - assertEvent(event, "Message"); - return event.data.tag === "HeadIsAborted"; - }, - isCommitted: ({ event }) => { - assertEvent(event, "Message"); - return event.data.tag === "Committed"; - }, - isOpen: ({ event }) => { - assertEvent(event, "Message"); - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Open"; - } - return event.data.tag === "HeadIsOpen"; - }, - isClosed: ({ event }) => { - assertEvent(event, "Message"); - if (event.data.tag === "Greetings") { - return event.data.headStatus === "Closed"; - } - return event.data.tag === "HeadIsClosed"; - }, - isContested: ({ event }) => { - assertEvent(event, "Message"); - return event.data.tag === "HeadIsContested"; - }, - isReadyToFanout: ({ event }) => { - assertEvent(event, "Message"); - if (event.data.tag === "Greetings") { - return event.data.headStatus === "FanoutPossible"; - } - return event.data.tag === "ReadyToFanout"; - }, - isFinalized: ({ event }) => { - assertEvent(event, "Message"); - return event.data.tag === "HeadIsFinalized"; - }, - }, - actors: { - 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) => { - sendBack({ type: "Message", data: JSON.parse(event.data) }); - }; - ws.onclose = (event) => { - sendBack({ type: "Disconnect", code: event.code }); - }; - - receive((event) => { - assertEvent(event, "Send"); - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event.data)); - else sendBack({ type: "Error", data: new Error("Connection is not open") }); - }); - - return () => ws.close(); - }), - commit: fromPromise( - 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; - return await client.post("/commit", request, undefined, signal); - } - ), - }, - types: { - context: {} as HydraContext, - events: {} as HydraEvent, - }, - }).createMachine({ - id: "HYDRA", - initial: "Disconnected", - context: { - baseURL: "", - headURL: "", - }, - states: { - Disconnected: { - on: { - Connect: { - target: "Connection", - actions: "setURL", - }, - }, - }, - Connection: { - invoke: { - src: "server", - input: ({ context }) => ({ - url: context.headURL, - }), - onDone: { - target: "Connected", - actions: "createClient", - }, - onError: "Disconnected", - }, - initial: "Connecting", - states: { - Connecting: { - on: { - Ready: { - target: "Done", - actions: "setConnection", - }, - }, - }, - Done: { type: "final" }, - }, - }, - Connected: { - on: { - Message: [ - { - target: ".Initializing", - guard: "isInitializing", - }, - { - target: ".Open", - guard: "isOpen", - }, - { - target: ".Closed", - guard: "isClosed", - }, - { - target: ".FanoutPossible", - guard: "isReadyToFanout", - }, - ], - Disconnect: { - target: "Disconnected", - actions: "closeConnection", - }, - Error: { actions: "setError" }, - }, - initial: "Idle", - states: { - Idle: { - on: { - Init: { actions: "initHead" }, - }, - always: { - target: "Initializing", - guard: "isInitializing", - }, - }, - Initializing: { - on: { - Abort: { actions: "abortHead" }, - }, - always: [ - { - target: "Open", - guard: "isOpen", - }, - { - target: "Final", - guard: "isAborted", - }, - ], - initial: "ReadyToCommit", - states: { - ReadyToCommit: { - on: { - Commit: { - target: "Committing", - actions: "setRequest", - }, - }, - }, - Committing: { - invoke: { - src: "commit", - input: ({ context }) => ({ - client: context.client, - request: context.request, - }), - onError: { - target: "ReadyToCommit", - }, - }, - always: { - target: "Done", - actions: "clearRequest", - guard: "isCommitted", - }, - }, - Done: { - type: "final", - }, - }, - }, - Open: { - on: { - Close: { actions: "closeHead" }, - NewTx: { actions: "newTx" }, - }, - always: { - target: "Closed", - guard: "isClosed", - }, - initial: "TODO", - states: { - TODO: {}, - }, - }, - Closed: { - on: { - Contest: { actions: "contestHead" }, - }, - always: [ - { - target: "Contested", - guard: "isContested", - }, - { - target: "FanoutPossible", - guard: "isReadyToFanout", - }, - ], - }, - FanoutPossible: { - on: { - Fanout: { actions: "fanoutHead" }, - }, - always: { - target: "Final", - guard: "isFinalized", - }, - }, - Final: { - on: { - Init: { actions: "initHead" }, - }, - always: { - target: "Initializing", - guard: "isInitializing", - }, - }, - Contested: {}, - }, - }, - }, - }); -} - -// Re-export the original machine for backward compatibility -export const machine = createHydraMachine(); diff --git a/packages/mesh-hydra/src/hydra-machine.test.ts b/packages/mesh-hydra/src/state-management/hydra-machine.test.ts similarity index 80% rename from packages/mesh-hydra/src/hydra-machine.test.ts rename to packages/mesh-hydra/src/state-management/hydra-machine.test.ts index e0a4e5f74..f79240a6d 100644 --- a/packages/mesh-hydra/src/hydra-machine.test.ts +++ b/packages/mesh-hydra/src/state-management/hydra-machine.test.ts @@ -1,97 +1,27 @@ import { createActor } from "xstate"; - -// Lightweight HTTP client stub injected via module mock -class HTTPClientStub { - public static instances: HTTPClientStub[] = []; - public static nextPostErrors: Error[] = []; - public static postCalls: Array<{ endpoint: string; payload: unknown }> = []; - public static nextPostResponses: unknown[] = []; - - constructor(public baseURL: string) { - HTTPClientStub.instances.push(this); - } - - async post(endpoint: string, payload: unknown) { - HTTPClientStub.postCalls.push({ endpoint, payload }); - if (HTTPClientStub.nextPostErrors.length > 0) { - throw HTTPClientStub.nextPostErrors.shift(); - } - if (HTTPClientStub.nextPostResponses.length > 0) { - return HTTPClientStub.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" }; +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; } } -// Mock the utils module to use our HTTPClient stub -jest.mock("./utils", () => ({ - HTTPClient: HTTPClientStub, -})); - -// Import after mocks are set up -import { machine } from "./hydra-machine"; - -// Minimal WebSocket stub compatible with the machine's server actor -class TestWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - - public readyState = TestWebSocket.CONNECTING; - public onopen: ((ev: any) => void) | null = null; - public onmessage: ((ev: { data: any }) => void) | null = null; - public onclose: ((ev: { code: number; reason?: string }) => void) | null = - null; - public onerror: ((ev: any) => void) | null = null; - public sentMessages: string[] = []; - - constructor(public url: string) { - // Simulate async open - setImmediate(() => { - this.readyState = TestWebSocket.OPEN; - this.onopen?.({}); - }); - } - - send(data: string) { - if (this.readyState !== TestWebSocket.OPEN) { - throw new Error("WebSocket is not open"); - } - this.sentMessages.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = TestWebSocket.CLOSED; - this.onclose?.({ code, reason }); - } - - // Test helper to simulate an incoming JSON message - mockReceive(obj: unknown) { - const data = typeof obj === "string" ? obj : JSON.stringify(obj); - this.onmessage?.({ data }); +// Create a mock WebSocketFactory +class MockWebSocketFactory implements WebSocketFactory { + create(url: string): WebSocket { + return new MockWebSocket(url) as unknown as WebSocket; } } -// Attach stub to globalThis before tests run -(globalThis as any).WebSocket = TestWebSocket as any; - const flush = () => new Promise((resolve) => setImmediate(resolve)); function stateToString(value: any): string { @@ -106,13 +36,14 @@ function stateToString(value: any): string { describe("hydra-machine state transitions", () => { let actor: any; - let ws: TestWebSocket; + let ws: MockWebSocket; beforeEach(() => { - HTTPClientStub.instances = []; - HTTPClientStub.nextPostErrors = []; - HTTPClientStub.postCalls = []; - HTTPClientStub.nextPostResponses = []; + MockHttpClient.reset(); + const machine = createHydraMachine({ + httpClientFactory: new MockHTTPClientFactory(), + webSocketFactory: new MockWebSocketFactory(), + }); actor = createActor(machine); actor.start(); }); @@ -140,7 +71,7 @@ describe("hydra-machine state transitions", () => { ); // Get WebSocket instance - ws = actor.getSnapshot().context.connection as unknown as TestWebSocket; + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; // Send Greetings with Idle status ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); @@ -166,6 +97,10 @@ describe("hydra-machine state transitions", () => { ]; for (const { headStatus, expectedState } of testCases) { + const machine = createHydraMachine({ + httpClientFactory: new MockHTTPClientFactory(), + webSocketFactory: new MockWebSocketFactory(), + }); const testActor = createActor(machine); testActor.start(); @@ -173,7 +108,7 @@ describe("hydra-machine state transitions", () => { await flush(); const testWs = testActor.getSnapshot().context - .connection as unknown as TestWebSocket; + .connection as unknown as MockWebSocket; testWs.mockReceive({ tag: "Greetings", headStatus }); expect(stateToString(testActor.getSnapshot().value)).toBe( @@ -188,7 +123,7 @@ describe("hydra-machine state transitions", () => { beforeEach(async () => { actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); await flush(); - ws = actor.getSnapshot().context.connection as unknown as TestWebSocket; + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); }); @@ -215,7 +150,7 @@ describe("hydra-machine state transitions", () => { await flush(); // HTTP was called - expect(HTTPClientStub.postCalls[0]).toEqual({ + expect(MockHttpClient.postCalls[0]).toEqual({ endpoint: "/commit", payload: request, }); @@ -276,7 +211,7 @@ describe("hydra-machine state transitions", () => { ws.mockReceive({ tag: "HeadIsInitializing" }); // First commit attempt will fail - set up error before sending - HTTPClientStub.nextPostErrors.push(new Error("Network error")); + MockHttpClient.nextPostErrors.push(new Error("Network error")); actor.send({ type: "Commit", data: { attempt: 1 } }); // Should be in RequestDraft state briefly @@ -314,7 +249,7 @@ describe("hydra-machine state transitions", () => { beforeEach(async () => { actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); await flush(); - ws = actor.getSnapshot().context.connection as unknown as TestWebSocket; + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; ws.mockReceive({ tag: "Greetings", headStatus: "Open" }); }); @@ -407,7 +342,7 @@ describe("hydra-machine state transitions", () => { beforeEach(async () => { actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); await flush(); - ws = actor.getSnapshot().context.connection as unknown as TestWebSocket; + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; ws.mockReceive({ tag: "Greetings", headStatus: "Open" }); }); @@ -451,7 +386,7 @@ describe("hydra-machine state transitions", () => { // Connect actor.send({ type: "Connect", baseURL: "http://localhost:4001" }); await flush(); - ws = actor.getSnapshot().context.connection as unknown as TestWebSocket; + ws = actor.getSnapshot().context.connection as unknown as MockWebSocket; // Start from idle ws.mockReceive({ tag: "Greetings", headStatus: "Idle" }); diff --git a/packages/mesh-hydra/src/state-management/hydra-machine.ts b/packages/mesh-hydra/src/state-management/hydra-machine.ts index 36171dd8c..518bd94b0 100644 --- a/packages/mesh-hydra/src/state-management/hydra-machine.ts +++ b/packages/mesh-hydra/src/state-management/hydra-machine.ts @@ -195,792 +195,848 @@ type HydraError = reason?: DecommitInvalidReason; }; -export const machine = 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 === 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: new HTTPClient(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, +// 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 }, }; - return { error: err }; - } - if (msg.tag === "CommandFailed") { - const typedMsg = msg as MsgCommandFailed; - const err: HydraError = { - kind: "CommandFailed", - message: "Command failed", - source: typedMsg, + }), + decommitUTxO: sendTo("server", ({ event }) => { + assertEvent(event, "Decommit"); + return { + type: "Send", + data: { tag: "Decommit", decommitTx: event.tx }, }; - 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, + }), + 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 }, }; - 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, + }), + + /** === Connection / context === */ + closeConnection: assign(({ context }) => { + if (context.connection?.readyState === WebSocket.OPEN) { + context.connection.close(1000, "Client disconnected"); + } + return { + baseURL: "", + headURL: "", + connection: undefined, + client: undefined, + error: undefined, + request: undefined, + draftTx: undefined, + signedDepositTx: undefined, }; - 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, + }), + 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}`, }; - 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" - ); - }, + }), + 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 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"; + /** === 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 {}; + }), }, - /** 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" - ); - }, + 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" + ); + }, - /** 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"; - }, + /** 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"; + }, - /** 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"; - }, + /** 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" + ); + }, - /** 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"; - }, + /** 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"; + }, - /** 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"; - }, + /** 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"; + }, - /** 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"; + /** 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"; + }, }, - 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 === 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, + ); + }), }, - }, - - actors: { - /** Long-lived WS actor */ - server: fromCallback( - ({ sendBack, receive, input }) => { - const ws = new WebSocket(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 === 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 */ - } - }; + + types: { + context: {} as { + baseURL: string; + client?: HTTPClient; + connection?: WebSocket; + error?: HydraError | unknown; + headURL: string; + request?: unknown; + draftTx?: Transaction; + signedDepositTx?: Transaction; + submitPath?: string; }, - ), - - 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 }, }, - 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" }, - }, + }).createMachine({ + id: "HYDRA", + initial: "Disconnected", + context: { + baseURL: "", + headURL: "", + submitPath: "/cardano-transaction", }, - 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" }, - ], - }, + states: { + Disconnected: { + on: { + Connect: { target: "Connected", actions: "setURL" }, }, + }, - NoHead: { - on: { - Init: { actions: ["clearError", "initHead"] }, - }, + Connected: { + invoke: { + id: "server", + src: "server", + input: ({ context }) => ({ url: context.headURL }), }, - 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"], - }, + 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" }, + ], }, - Depositing: { - initial: "ReadyToCommit" as const, - on: { - SubmitSignedDeposit: { target: ".SubmittingDeposit" }, - DepositSubmittedExternally: { - target: ".AwaitingCommitConfirmation", + }, + + 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"], + }, }, }, - states: { - ReadyToCommit: { - on: { - Commit: { - target: "RequestDraft", - actions: ["clearError", "setRequest"], - }, + Depositing: { + initial: "ReadyToCommit" as const, + on: { + SubmitSignedDeposit: { target: ".SubmittingDeposit" }, + DepositSubmittedExternally: { + target: ".AwaitingCommitConfirmation", }, }, - - 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, - })), + states: { + ReadyToCommit: { + on: { + Commit: { + target: "RequestDraft", + actions: ["clearError", "setRequest"], + }, }, - onError: { target: "ReadyToCommit", actions: "setError" }, }, - }, - AwaitSignature: { - on: { - SubmitSignedDeposit: { - target: "SubmittingDeposit", - actions: assign(({ event }: any) => { - assertEvent(event, "SubmitSignedDeposit"); - return { signedDepositTx: event.tx as Transaction }; + 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", + }, }, - DepositSubmittedExternally: { - target: "AwaitingCommitConfirmation", + }, + + 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", + 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"], + AwaitingCommitConfirmation: { + on: { + Message: { + guard: "isLegacyCommitEvent", + target: "Done", + actions: ["clearRequest", "clearDraftTx"], + }, }, }, - }, - Done: { type: "final" as const }, - }, - onDone: { - // Return to Waiting after successful commit - target: "Waiting", + 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"], - }, + 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: [] }, + ], }, - Depositing: { - initial: "ReadyToCommit" as const, - on: { - SubmitSignedDeposit: { target: ".SubmittingDeposit" }, - DepositSubmittedExternally: { - target: ".AwaitingCommitConfirmation", + initial: "Active", + states: { + Active: { + on: { + Commit: { + target: "#HYDRA.Connected.Open.Depositing.RequestDraft", + actions: ["clearError", "setRequest"], + }, }, }, - states: { - ReadyToCommit: { - on: { - Commit: { - target: "RequestDraft", - actions: ["clearError", "setRequest"], - }, + Depositing: { + initial: "ReadyToCommit" as const, + on: { + SubmitSignedDeposit: { target: ".SubmittingDeposit" }, + DepositSubmittedExternally: { + target: ".AwaitingCommitConfirmation", }, }, - - 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, - })), + states: { + ReadyToCommit: { + on: { + Commit: { + target: "RequestDraft", + actions: ["clearError", "setRequest"], + }, }, - onError: { target: "ReadyToCommit", actions: "setError" }, }, - }, - AwaitSignature: { - on: { - SubmitSignedDeposit: { - target: "SubmittingDeposit", - actions: assign(({ event }: any) => { - assertEvent(event, "SubmitSignedDeposit"); - return { signedDepositTx: event.tx as Transaction }; + 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", + }, }, - DepositSubmittedExternally: { - target: "AwaitingCommitConfirmation", + }, + + 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", + 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"], + 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", + 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: [], - }, - ], + Closed: { + on: { + Contest: { actions: ["clearError", "contestHead"] }, + Message: [ + { + guard: "isContested", + // Stay in Closed state, just update internal state + actions: [], + }, + ], + }, }, - }, - FanoutPossible: { - on: { - Fanout: { actions: ["clearError", "fanoutHead"] }, + FanoutPossible: { + on: { + Fanout: { actions: ["clearError", "fanoutHead"] }, + }, }, - }, - Final: { - on: { - Init: { actions: ["clearError", "initHead"] }, + 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 000000000..e69de29bb From 5cae333fb44a98c883566f7a24339bad6d532508 Mon Sep 17 00:00:00 2001 From: lisicky Date: Mon, 25 Aug 2025 03:58:55 +0900 Subject: [PATCH 18/19] add int tests --- packages/mesh-hydra/package.json | 11 +- packages/mesh-hydra/src/mocks/index.ts | 2 + .../src/state-management/hydra-machine.ts | 10 +- .../hydra-machine-integration.test.ts | 519 ++++++++++++++++++ .../hydra-machine-simple-integration.test.ts | 411 ++++++++++++++ 5 files changed, 946 insertions(+), 7 deletions(-) create mode 100644 packages/mesh-hydra/src/mocks/index.ts create mode 100644 packages/mesh-hydra/tests/integration/hydra-machine-integration.test.ts create mode 100644 packages/mesh-hydra/tests/integration/hydra-machine-simple-integration.test.ts diff --git a/packages/mesh-hydra/package.json b/packages/mesh-hydra/package.json index 209b84a0d..daddffafd 100644 --- a/packages/mesh-hydra/package.json +++ b/packages/mesh-hydra/package.json @@ -24,7 +24,10 @@ "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", @@ -35,8 +38,12 @@ "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/mocks/index.ts b/packages/mesh-hydra/src/mocks/index.ts new file mode 100644 index 000000000..33dc1d904 --- /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.ts b/packages/mesh-hydra/src/state-management/hydra-machine.ts index 518bd94b0..9ad75e6cf 100644 --- a/packages/mesh-hydra/src/state-management/hydra-machine.ts +++ b/packages/mesh-hydra/src/state-management/hydra-machine.ts @@ -268,7 +268,8 @@ export function createHydraMachine(config: HydraMachineConfig = {}) { /** === Connection / context === */ closeConnection: assign(({ context }) => { - if (context.connection?.readyState === WebSocket.OPEN) { + if (context.connection?.readyState === 1) { + // WebSocket.OPEN context.connection.close(1000, "Client disconnected"); } return { @@ -481,9 +482,7 @@ export function createHydraMachine(config: HydraMachineConfig = {}) { }, isPostTxFailed: ({ event }) => { assertEvent(event, "Message"); - return ( - (event.data as HydraServerOutput).tag === "PostTxOnChainFailed" - ); + return (event.data as HydraServerOutput).tag === "PostTxOnChainFailed"; }, isDecommitInvalid: ({ event }) => { assertEvent(event, "Message"); @@ -600,7 +599,8 @@ export function createHydraMachine(config: HydraMachineConfig = {}) { receive((event) => { assertEvent(event, "Send"); - if (ws.readyState === WebSocket.OPEN) + if (ws.readyState === 1) + // WebSocket.OPEN ws.send(JSON.stringify(event.data)); else sendBack({ 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 000000000..5c976ea53 --- /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 000000000..991debd0a --- /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`, + ); + } + } + }); +}); From a1789daf94aec0844e10b86f39032e1ff162728f Mon Sep 17 00:00:00 2001 From: lisicky Date: Mon, 15 Sep 2025 01:23:55 +0900 Subject: [PATCH 19/19] update controller code --- packages/mesh-hydra/src/hydra-controller.ts | 98 ++++++++++++++++++--- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/mesh-hydra/src/hydra-controller.ts b/packages/mesh-hydra/src/hydra-controller.ts index d099303a1..5157ef13c 100644 --- a/packages/mesh-hydra/src/hydra-controller.ts +++ b/packages/mesh-hydra/src/hydra-controller.ts @@ -1,5 +1,9 @@ -import { ActorRefFrom, createActor, StateValue } from "xstate"; -import { machine } from "./state-management/hydra-machine"; +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"; @@ -13,14 +17,32 @@ type ConnectOptions = { type HydraStateName = | "*" | "Disconnected" - | "Connecting" - | "Connected.Idle" - | "Connected.Initializing.ReadyToCommit" + | "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["getSnapshot"]>; +type Snapshot = ReturnType< + ActorRefFrom>["getSnapshot"] +>; type Events = { "*": (snapshot: Snapshot) => void; @@ -29,13 +51,15 @@ type Events = { }; export class HydraController { - private actor = createActor(machine); + private actor: ActorRefFrom>; private emitter = new Emitter(); private _currentSnapshot?: Snapshot; private httpClient?: HTTPClient; + private subscription?: Subscription; - constructor() { - this.actor.subscribe({ + 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), }); @@ -50,30 +74,54 @@ export class HydraController { /** Protocol commands */ init() { + this.validateStateForOperation("Init", [ + "Connected.NoHead", + "Connected.Final", + ]); this.actor.send({ type: "Init" }); } - commit(data: unknown = {}) { + + commit(data: Record = {}) { + this.validateStateForOperation("Commit", [ + "Connected.Initializing.Waiting", + "Connected.Initializing.Depositing.ReadyToCommit", + "Connected.Open.Active", + ]); this.actor.send({ type: "Commit", data }); } - newTx(tx: string) { + + 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: string) { + + 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 }); } @@ -123,12 +171,12 @@ export class HydraController { return await this.httpClient.get("/protocol-parameters"); } - async submitCardanoTransaction(tx: unknown) { + async submitCardanoTransaction(tx: Transaction) { if (!this.httpClient) throw new Error("Not connected"); return await this.httpClient.post("/cardano-transaction", tx); } - async submitL2Transaction(tx: unknown) { + async submitL2Transaction(tx: Transaction) { if (!this.httpClient) throw new Error("Not connected"); return await this.httpClient.post("/transaction", tx); } @@ -176,10 +224,32 @@ export class HydraController { } 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() {