diff --git a/mkdocs/config/mkdocs.base.yml b/mkdocs/config/mkdocs.base.yml index 5076ccd06..815f712d3 100644 --- a/mkdocs/config/mkdocs.base.yml +++ b/mkdocs/config/mkdocs.base.yml @@ -17,6 +17,7 @@ plugins: - js - java - ws + - req - ser - _ mkdoxy: # Generate API reference docs for Java diff --git a/mkdocs/config/mkdocs.en.yml b/mkdocs/config/mkdocs.en.yml index 13c7dc20b..1effb58ad 100644 --- a/mkdocs/config/mkdocs.en.yml +++ b/mkdocs/config/mkdocs.en.yml @@ -101,6 +101,7 @@ nav: - devbook/chain/chain-heights.md - WebSockets: - devbook/websockets/listen-new-blocks.md + - devbook/websockets/listen-transaction-flow.md - Reference Guides: - Python SDK: devbook/reference/py/ - TypeScript SDK: devbook/reference/ts/ diff --git a/mkdocs/overrides/assets/stylesheets/extra.css b/mkdocs/overrides/assets/stylesheets/extra.css index a069e8655..543b195ad 100644 --- a/mkdocs/overrides/assets/stylesheets/extra.css +++ b/mkdocs/overrides/assets/stylesheets/extra.css @@ -714,6 +714,10 @@ code.doc-symbol-interface::after { background-color: #b50505; } +.md-typeset code.rest-method-req { + background-color: #8e6fc9; +} + /* Graphviz diagrams */ .graphviz { display: block; @@ -773,11 +777,21 @@ code.doc-symbol-interface::after { background: var(--md-accent-fg-color--transparent); } +[data-md-color-scheme=default] .md-typeset table:not([class]) th { + color: var(--md-typeset-color); +} + .centered .md-typeset__table { display: table; margin: 0 auto; } +.md-typeset .frame-table table:not([class]) { + display: table; + width: 100%; + table-layout: fixed; +} + /* Tables with subsections */ .md-typeset .subsections table td:not(:has(strong)):first-child { padding-left: 2rem; diff --git a/mkdocs/pages/en/devbook/reference/websockets/index.md b/mkdocs/pages/en/devbook/reference/websockets/index.md index 9e0a5d970..54caabf0b 100644 --- a/mkdocs/pages/en/devbook/reference/websockets/index.md +++ b/mkdocs/pages/en/devbook/reference/websockets/index.md @@ -1,17 +1,54 @@ # WebSockets -To get **live updates** when an event occurs on the blockchain, NEM publishes -[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API). +NEM publishes blockchain events over +[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API), so applications can receive live updates +without constantly polling the [REST API](../rest/nem.md). -Client applications can open a WebSocket connection and subscribe to any of the available channels instead of needing -to constantly poll the [REST API](../rest/nem.md) for updates. +Client applications open a WebSocket connection to any in the network and subscribe to the [channels](#channels) +they want to monitor. +When an event occurs on a channel, the node notifies every subscribed client in real time. -When an event occurs in a channel, the sends a notification to every subscribed client in real-time. +Some channels also accept [requests](#requests) for immediate data, similar to the REST API. +This can simplify applications that use WebSockets as their only API for both live notifications and on-demand updates. + +## Connection NEM serves WebSockets using the [STOMP](https://stomp.github.io/) messaging protocol over [SockJS](https://github.com/sockjs/sockjs-client), on a dedicated port (`7778` by default) separate from the HTTP API port. -The SockJS endpoint is `/w/messages`, for example: `http://localhost:7778/w/messages`. + +The SockJS endpoint is `/w/messages`, for example `http://localhost:7778/w/messages`. + +Clients typically connect using either a SockJS client library or the native WebSocket API, together with a STOMP client +library to handle messaging. + +??? note "Connecting using native WebSockets" + + SockJS provides a WebSocket-like transport with cross-browser support and HTTP-based fallback options when native + WebSockets are unavailable. + + Clients with native WebSocket support can connect directly to the SockJS WebSocket transport endpoint at + `/w/messages/websocket`, for example: `ws://localhost:7778/w/messages/websocket`. + + This uses the WebSocket transport of SockJS without requiring the SockJS client library, + while still relying on the SockJS server. + +## STOMP Session + +STOMP Session +: A client-node conversation over a WebSocket connection, following the [STOMP](https://stomp.github.io/) messaging + protocol. + +A client controls the session by exchanging with the node: + +1. Send a `CONNECT` frame to start the STOMP session. +2. Send a `SUBSCRIBE` frame for each to monitor. + Each subscription requires a client-defined `id`. +3. Send an optional [registration request](#registration-requests) with a `SEND` frame to enable notifications for + channels that require explicit registration. +4. Receive a `MESSAGE` frame from the node for every event on a subscribed channel. +5. Send an `UNSUBSCRIBE` frame for each subscribed channel to stop receiving its notifications. +6. Send a `DISCONNECT` frame to end the session. !!! warning "Connections can drop silently" @@ -20,24 +57,32 @@ The SockJS endpoint is `/w/messages`, for example: `http://localhost:7778/w/mess Reconnection starts a fresh session, so every channel must be subscribed again. -## Request Format +## STOMP Frames + +STOMP Frame +: A plain-text message adhering to the [STOMP](https://stomp.github.io/) protocol, + made of a command, optional `header:value` lines, and an optional body. -After opening the SockJS connection, the client drives the session with STOMP frames. -A _frame_ is a plain-text message made of a command, optional `header:value` lines, and an optional body. +The client and node exchange the following frame types. -The client uses these frames: +### `CONNECT` -| Frame | Purpose | -| ------------- | --------------------------------------------------------------------------------- | -| `CONNECT` | Starts the STOMP session. Sent once, right after the connection opens. | -| `SUBSCRIBE` | Subscribes to a channel, with a client-chosen `id` and the channel `destination`. | -| `SEND` | Sends a request to a `/w/api` destination, for example to register an account. | -| `UNSUBSCRIBE` | Cancels a subscription by its `id`. | -| `DISCONNECT` | Ends the session. | +Starts the STOMP session. +The client must send this frame once, right after the connection opens. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#CONNECT_or_STOMP_Frame) for full details. -To subscribe to a channel, send a `SUBSCRIBE` frame with a client-chosen `id` and the channel `destination`: +```stomp title="Example" +CONNECT +accept-version:1.2 +heart-beat:0,0 +``` -```stomp title="SUBSCRIBE frame" +### `SUBSCRIBE` + +Subscribes to a , with a client-chosen `id` and `destination`. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#SUBSCRIBE) for full details. + +```stomp title="Example" SUBSCRIBE id:sub-0 destination:/blocks @@ -45,13 +90,15 @@ destination:/blocks * `id` is unique only within a single connection (other clients can reuse the same value). It is echoed back as the `subscription` header on every message and used to `UNSUBSCRIBE` later. -* `destination` is one of the [channels](#channels) listed below. +* `destination` identifies the channel, so the same connection can monitor multiple channels. -## Response Format +### `MESSAGE` -Each update is delivered as a STOMP `MESSAGE` frame: +Delivers channel data from the node. +It is the only frame type the node sends. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#MESSAGE) for full details. -```stomp title="MESSAGE frame" +```stomp title="Example" MESSAGE destination:/blocks subscription:sub-0 @@ -60,57 +107,112 @@ message-id:befkedjj-6247 { ... } ``` -* The `destination` header identifies the channel, so the same connection can monitor multiple channels. -* The `subscription` header is the `id` from the `SUBSCRIBE` frame. -* The `message-id` header is a unique identifier the server assigns to each message. -* The frame body is a channel-specific JSON object, described per [channel](#channels) below. +* `destination` matches the channel from the `SUBSCRIBE` frame. +* `subscription` matches the `id` from the `SUBSCRIBE` frame. +* `message-id` is a unique identifier the server assigns to each message. +* `{ ... }` is the body, a JSON object whose shape depends on the [channel](#channels). + See the **Message body** tabs below. + +### `SEND` + +Sends a to a `/w/api` destination. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#SEND) for full details. + +```stomp title="Example" +SEND +destination:/w/api/account/subscribe + +{ "account": "{address}" } +``` + +### `UNSUBSCRIBE` + +Cancels a subscription by its `id`. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#UNSUBSCRIBE) for full details. + +```stomp title="Example" +UNSUBSCRIBE +id:sub-0 +``` + +### `DISCONNECT` + +Ends the session. +See the [STOMP specification](https://stomp.github.io/stomp-specification-1.2.html#DISCONNECT) for full details. + +```stomp title="Example" +DISCONNECT +``` ## Channels +WebSocket Channel +: Node notifications are grouped into channels. + Clients subscribe to each channel whose notifications they want to receive. + +Every channel is subscribed to with a [`SUBSCRIBE`](#subscribe) frame. + The available channels are grouped here by the type of event they report. -### Block channels +### Block Channels + +These channels report new blocks as they are added to the chain. #### `/blocks` ws:blocks : Notifies subscribed clients every time a new block is added to the chain. -=== "Request body" - - ```stomp - SUBSCRIBE - id:sub-0 - destination:/blocks - ``` - -=== "Response body" +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/blocks +``` + +```stomp +MESSAGE +destination:/blocks +subscription:sub-0 +message-id:... - [Block](../rest/nem.md#model/Block) +{ ... } +``` +Followed by a [Block](../rest/nem.md#model/Block) JSON body. +
#### `/blocks/new` -ws:blocks-new +ws:blocks/new : Notifies subscribed clients of the new chain height every time a new block is added. A lighter alternative to `/blocks` when only the height is needed. -=== "Request body" - - ```stomp - SUBSCRIBE - id:sub-0 - destination:/blocks/new - ``` +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/blocks/new +``` + +```stomp +MESSAGE +destination:/blocks/new +subscription:sub-0 +message-id:... -=== "Response body" +{ "height": 1234567 } +``` +
- ```json - { - "height": 1234567 - } - ``` +### Transaction Channels -### Transaction channels +These channels report transaction activity, regardless of the accounts involved. #### `/unconfirmed` @@ -118,80 +220,463 @@ ws:unconfirmed : Notifies subscribed clients every time a transaction enters the , regardless of the accounts involved. -=== "Request body" - - ```stomp - SUBSCRIBE - id:sub-0 - destination:/unconfirmed - ``` - -=== "Response body" +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/unconfirmed +``` + +```stomp +MESSAGE +destination:/unconfirmed +subscription:sub-0 +message-id:... - [Transaction](../rest/nem.md#model/Transaction) +{ ... } +``` +Followed by a [Transaction](../rest/nem.md#model/Transaction) JSON body. +
-### Account channels +### Account Channels -!!! note "Account channels require registration" +These channels report activity for a specific account, such as its balance, transactions, and the mosaics and +namespaces it owns. - In order to receive notifications for account channels, each account has to be registered by sending a `SEND` frame - to `/w/api/account/subscribe`: +!!! note "Address format" - ```stomp - SEND - destination:/w/api/account/subscribe + Wherever an address appears, either in a channel `destination` or a request body, it uses the + [encoded address](../../../textbook/cryptography.md#addresses) format: + uppercase letters and digits, without hyphens. - { "account": "{address}" } - ``` + **Example:** `TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4`. #### `/account/{address}` ws:account/{address} : Notifies subscribed clients when the account's state, such as its balance, changes. + Requires the address to be [registered](#registration-requests) first. -=== "Request body" - - ```stomp - SUBSCRIBE - id:sub-0 - destination:/account/{address} - ``` - -=== "Response body" +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/account/{address} +``` + +```stomp +MESSAGE +destination:/account/{address} +subscription:sub-0 +message-id:... - [AccountMetaDataPair](../rest/nem.md#model/AccountMetaDataPair) +{ ... } +``` +Followed by an [AccountMetaDataPair](../rest/nem.md#model/AccountMetaDataPair) JSON body. +
#### `/unconfirmed/{address}` ws:unconfirmed/{address} -: Notifies subscribed clients every time a transaction involving the registered account enters the - . +: Notifies subscribed clients every time a transaction involving the account enters the . + Requires the address to be [registered](#registration-requests) first. + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/unconfirmed/{address} +``` + +```stomp +MESSAGE +destination:/unconfirmed/{address} +subscription:sub-0 +message-id:... -=== "Request body" +{ ... } +``` +Followed by a [TransactionMetaDataPair](../rest/nem.md#model/TransactionMetaDataPair) JSON body. +
- ```stomp - SUBSCRIBE - id:sub-0 - destination:/unconfirmed/{address} - ``` +#### `/transactions/{address}` + +ws:transactions/{address} +: Notifies subscribed clients when a transaction involving the account is confirmed. + Requires the address to be [registered](#registration-requests) first. + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/transactions/{address} +``` + +```stomp +MESSAGE +destination:/transactions/{address} +subscription:sub-0 +message-id:... -=== "Response body" +{ ... } +``` +Followed by a [TransactionMetaDataPair](../rest/nem.md#model/TransactionMetaDataPair) JSON body. +
- [TransactionMetaDataPair](../rest/nem.md#model/TransactionMetaDataPair) +#### `/account/mosaic/owned/{address}` -#### `/transactions/{address}` +ws:account/mosaic/owned/{address} +: Notifies subscribed clients when a block changes the mosaics the account owns. -ws:transactions/{address} -: Notifies subscribed clients when a transaction involving the registered account is confirmed. +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/account/mosaic/owned/{address} +``` + +```stomp +MESSAGE +destination:/account/mosaic/owned/{address} +subscription:sub-0 +message-id:... + +{ ... } +``` +Followed by a [Mosaic](../rest/nem.md#model/Mosaic) JSON body. +
+ +#### `/account/mosaic/owned/definition/{address}` + +ws:account/mosaic/owned/definition/{address} +: Notifies subscribed clients when a block changes the mosaic definitions the account owns. + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/account/mosaic/owned/definition/{address} +``` + +```stomp +MESSAGE +destination:/account/mosaic/owned/definition/{address} +subscription:sub-0 +message-id:... + +{ ... } +``` +Followed by a [MosaicDefinitionSupplyTuple](../rest/nem.md#model/MosaicDefinitionSupplyTuple) JSON body. +
+ +#### `/account/namespace/owned/{address}` + +ws:account/namespace/owned/{address} +: Notifies subscribed clients when a block changes the namespaces the account owns. + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/account/namespace/owned/{address} +``` + +```stomp +MESSAGE +destination:/account/namespace/owned/{address} +subscription:sub-0 +message-id:... + +{ ... } +``` +Followed by a [Namespace](../rest/nem.md#model/Namespace) JSON body. +
+ +#### `/recenttransactions/{address}` + +ws:recenttransactions/{address} +: Notifies subscribed clients of the account's 25 most recent confirmed transactions, only in response to + . + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/recenttransactions/{address} +``` + +```stomp +MESSAGE +destination:/recenttransactions/{address} +subscription:sub-0 +message-id:... + +{ ... } +``` +Followed by a list of [TransactionMetaDataPair](../rest/nem.md#model/TransactionMetaDataPair) wrapped in a `data` field. +
+ +### System Channels + +These channels report node status and request errors, rather than blockchain events. + +#### `/node/info` -=== "Request body" +ws:node/info +: Notifies subscribed clients of the node's information, only in response to . + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/node/info +``` + +```stomp +MESSAGE +destination:/node/info +subscription:sub-0 +message-id:... + +{ ... } +``` +Followed by a [Node](../rest/nem.md#model/Node) JSON body. +
+ +#### `/errors` + +ws:errors +: Notifies subscribed clients when a `/w/api` fails, for example when its address payload is invalid. + A client can subscribe to this channel right after connecting, so problems surface here instead of being silently + dropped. + +
+ + +
:material-arrow-up-bold: Subscription frame:material-arrow-down-bold: Notification frame
+```stomp +SUBSCRIBE +id:sub-0 +destination:/errors +``` + +```stomp +MESSAGE +destination:/errors +subscription:sub-0 +message-id:... + +{ + "timeStamp": 67191609, + "status": 400, + "error": "Bad Request", + "message": "account is not valid" +} +``` +
- ```stomp - SUBSCRIBE - id:sub-0 - destination:/transactions/{address} - ``` +## Requests -=== "Response body" +WebSocket Request +: A message sent by the client to make the node deliver: + either notifications on channels that require **registration**, or an immediate notification containing a + **snapshot** of the state of the blockchain. - [TransactionMetaDataPair](../rest/nem.md#model/TransactionMetaDataPair) +Requests are **read-only** and do not modify the chain state. + +All requests are sent with a [`SEND`](#send) frame to a destination that begins with `/w/api/`. +Some requests return no answer, while others return results through one of the [channels](#channels) above. + +### Registration Requests + +Some [account channels](#account-channels) stay silent until the address is registered. +These requests perform that registration, so those channels begin delivering notifications. + +#### `/w/api/account/subscribe` + +req:w/api/account/subscribe +: Registers the address so the node starts sending notifications on [account channels](#account-channels). + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/subscribe + +{ "account": "{address}" } +``` +
+ +#### `/w/api/account/get` + +req:w/api/account/get +: Registers the address like , and forces the node to send a + notification containing the account's current state to . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/get + +{ "account": "{address}" } +``` +
+ +### Snapshot Requests + +Each request forces the node to send an immediate snapshot of current data to a channel, without waiting for a new +event. + +This allows applications to fetch current data on demand through the same channels used for live updates, instead of polling the [REST API](../rest/nem.md). + +#### `/w/api/account/transfers/all` + +req:w/api/account/transfers/all +: Forces the node to send a notification containing the account's 25 most recent confirmed transactions + to , and another with up to 10 pending ones to . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/transfers/all + +{ "account": "{address}" } +``` +
+ +#### `/w/api/account/transfers/unconfirmed` + +req:w/api/account/transfers/unconfirmed +: Forces the node to send a notification containing up to 10 of the account's most recent pending + transactions to . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/transfers/unconfirmed + +{ "account": "{address}" } +``` +
+ +#### `/w/api/account/mosaic/owned` + +req:w/api/account/mosaic/owned +: Forces the node to send a notification containing the mosaics the account owns to + . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/mosaic/owned + +{ "account": "{address}" } +``` +
+ +#### `/w/api/account/mosaic/owned/definition` + +req:w/api/account/mosaic/owned/definition +: Forces the node to send a notification containing the mosaic definitions the account owns to + . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/mosaic/owned/definition + +{ "account": "{address}" } +``` +
+ +#### `/w/api/account/namespace/owned` + +req:w/api/account/namespace/owned +: Forces the node to send a notification containing the namespaces the account owns to + . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/account/namespace/owned + +{ "account": "{address}" } +``` +
+ +#### `/w/api/block/last` + +req:w/api/block/last +: Forces the node to send a notification containing the latest block to . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/block/last +``` +
+ +#### `/w/api/node/info` + +req:w/api/node/info +: Forces the node to send a notification containing its own information to . + +
+ + +
:material-arrow-up-bold: Request frame
+```stomp +SEND +destination:/w/api/node/info +``` +
diff --git a/mkdocs/pages/en/devbook/websockets/listen-new-blocks.md b/mkdocs/pages/en/devbook/websockets/listen-new-blocks.md index 283155e14..8f0f98985 100644 --- a/mkdocs/pages/en/devbook/websockets/listen-new-blocks.md +++ b/mkdocs/pages/en/devbook/websockets/listen-new-blocks.md @@ -5,7 +5,8 @@ tutorial_level: beginner # Listening to New Blocks -The WebSocket channel sends a real-time notification every time a new is added to the chain. +The sends a real-time notification every time a new is +added to the chain. Compared to polling the endpoint, WebSockets push updates as they happen without the overhead of repeated API calls. @@ -51,9 +52,11 @@ See the [WebSocket reference](../reference/websockets/index.md) for details on t for convenience. The snippet uses the `NODE_URL` environment variable to set the NEM . -WebSockets are served on a dedicated port (`7778` by default), separate from the REST API port (`7890`). If no value is provided, a default one is used. +`WS_URL` defines the WebSocket endpoint for the same node. +It is derived from `NODE_URL` by replacing port `7890` with `7778`. + The program runs until interrupted with `Ctrl+C`, which triggers the unsubscribe step before closing the connection. ## Code Explanation @@ -62,14 +65,15 @@ The program runs until interrupted with `Ctrl+C`, which triggers the unsubscribe {{ tutorial.code_snippet_tagged('step-1') }} -The first step is to open a connection to the node's `/w/messages` endpoint and start a STOMP session over it. +The first step is to open a connection to the node's `/w/messages` endpoint and start a +over it. ### Subscribing to the Channel {{ tutorial.code_snippet_tagged('step-2') }} -The code subscribes to the channel, which fires every time a new block is added to the chain -(approximately every minute). +The code subscribes to the channel. +The node then notifies subscribers every time a new block is added to the chain (approximately every minute). The subscription is given an `id` (`id-0`) which is used to unsubscribe on exit. @@ -79,7 +83,7 @@ Each incoming message is then passed to the formatting logic below. {{ tutorial.code_snippet_tagged('step-3') }} -The body of each incoming STOMP message is the new block, following the [Block](../reference/rest/nem.md#model/Block) +The body of each incoming message is the new block, following the [Block](../reference/rest/nem.md#model/Block) schema. For each message, the snippet prints two of its fields: diff --git a/mkdocs/pages/en/devbook/websockets/listen-transaction-flow.md b/mkdocs/pages/en/devbook/websockets/listen-transaction-flow.md new file mode 100644 index 000000000..433d3f168 --- /dev/null +++ b/mkdocs/pages/en/devbook/websockets/listen-transaction-flow.md @@ -0,0 +1,192 @@ +--- +title: Transaction Flow +tutorial_level: beginner +--- + +# Listening to Transaction Flow + +NEM provides that send real-time notifications as a moves +through the confirmation process for a specific . +Compared to polling the endpoint, WebSockets push updates as they happen without the overhead +of repeated API calls. + +This tutorial shows how to subscribe to transaction channels, announce a minimal +[Transfer Transaction](../transactions/transfer-xem.md), and wait for its confirmation using WebSockets. + +!!! note "Alternative: Polling" + + For a polling-based approach, see the + [Monitoring Transaction Status](../transactions/monitoring-status.md) tutorial. + +## Prerequisites + +Before you start, make sure to: + +* Set up your development environment. + See [Setting Up a Development Environment](../start/setup.md). +* Have the address of the account to monitor. +* Have an account with enough balance for transaction fees. + See [Creating an Account from a Private Key](../accounts/create-from-private-key.md) or + [Creating an Account by Using a Wallet](../../userbook/wallet/create-account.md). + +Additionally, NEM serves WebSockets using the [STOMP](https://stomp.github.io/) messaging protocol over +[SockJS](https://github.com/sockjs/sockjs-client), so a STOMP client and a WebSocket transport are required: + +=== ":simple-python: Python" + + Install the `stomper` and `websockets` libraries: + + ```bash + pip install stomper websockets + ``` + +=== ":simple-javascript: JavaScript" + + Install the `@stomp/stompjs` and `sockjs-client` libraries: + + ```bash + npm install @stomp/stompjs sockjs-client + ``` + +See the [WebSocket reference](../reference/websockets/index.md) for details on the connection protocol. + +## Full Code + +{% import 'tutorial.jinja2' as tutorial with context %} + +{{ tutorial.code_full_tagged('devbook/websockets/listen_transaction_flow', ['py', 'js']) }} + +!!! note + + There is no SockJS client library for Python, so a few small helper methods are defined at the top of the file + for convenience. + +The snippet uses the `NODE_URL` environment variable to set the NEM . +If no value is provided, a default one is used. + +`WS_URL` defines the WebSocket endpoint for the same node. +It is derived from `NODE_URL` by replacing port `7890` with `7778`. + +## Code Explanation + +### Setting Up the Monitored Address and Signer + +{{ tutorial.code_snippet_tagged('step-1') }} + +Each transaction channel is scoped to a specific address. +The channels send a notification whenever this address is involved in a transaction, as sender or recipient. +The `MONITOR_ADDRESS` environment variable sets the address to watch. +The WebSocket API expects it uppercase and without hyphens. + +To trigger notifications, this tutorial sends a transfer transaction to the monitored address. +The sender's private key is read from `SIGNER_PRIVATE_KEY`. + +If any of these environment variables is not provided, the tutorial provides default values. + +### Connecting to the WebSocket + +{{ tutorial.code_snippet_tagged('step-2') }} + +The code opens a SockJS connection to the `/w/messages` endpoint on `WS_URL` and starts a +over it. + +### Registering the Account + +{{ tutorial.code_snippet_tagged('step-3') }} + +To receive notifications on an account's transaction channels, the address must first be **registered** with the node. + +Before sending the registration request, the code temporarily subscribes to the channel +using `id-0`, so it can capture the notification that confirms registration. + +The code then sends a request to register the address. + +Once the address is registered, the node sends the account’s current state on the same +channel, which serves as the registration confirmation. + +When the notification arrives, the temporary subscription to the account messages channel is dropped using the same +`id-0` identifier. + +### Subscribing to the Channels + +{{ tutorial.code_snippet_tagged('step-4') }} + +With the account's address registered, the code subscribes to two address-scoped channels: + +* : Notifies when a transaction involving the address enters the , + waiting to be included in a block. +* : Notifies when a transaction involving the address is included in a . + +The subscriptions use IDs `id-1` and `id-2`, which identify them when the code unsubscribes after confirmation. + +### Building and Signing a Transfer Transaction + +{{ tutorial.code_snippet_tagged('step-5') }} + +This tutorial builds a minimal to the monitored address, with a zero amount, no mosaics, and no message. +A transfer is used for simplicity, but any transaction type triggers the same WebSocket notifications. + +The transaction follows the same process described in the +[Transfer Transaction tutorial](../transactions/transfer-xem.md): fetching the network time, creating the transaction, +and signing it. +The hash is computed locally so it can be matched against incoming messages later. + +### Announcing and Waiting for Confirmation + +{{ tutorial.code_snippet_tagged('step-6') }} + +The code announces the transaction to the endpoint and checks the result. +If the node rejected it, the code prints the rejection reason and stops. + +Otherwise, the code waits for confirmation, printing each message from the two subscribed channels. +Each message follows the [TransactionMetaDataPair](../reference/rest/nem.md#model/TransactionMetaDataPair) schema, whose +`meta.hash.data` field holds the transaction hash. + +When a message from the channel arrives whose hash matches the announced transaction, +the program prints a confirmation message and exits. + +!!! warning "Announce after subscribing to channels" + + Always announce the transaction **after** subscribing to the WebSocket channels to ensure the listener is ready. + Otherwise, notifications could arrive before the WebSocket is listening. + +The expected sequence for a successful transaction is described in the +[Transaction Lifecycle](../../textbook/transactions.md#transaction-lifecycle) section: + +1. `unconfirmed`: The transaction enters the . +2. `confirmed`: The transaction is included in a . + +### Unsubscribing from Channels + +{{ tutorial.code_snippet_tagged('step-7') }} + +After confirmation, the code unsubscribes from both channels and ends the STOMP session before the connection closes. + +## Output + +```text linenums="1" hl_lines="2 3 4 5 6 7 8 9 10 11" +--8<-- 'devbook/websockets/listen_transaction_flow.log' +``` + +The output shows: + +* **Address** (line 2): The monitored address. +* **Connection** (line 3): The STOMP session is established over the node's WebSocket endpoint at port `7778`. +* **Registration** (line 4): The account registration is confirmed before subscribing. +* **Subscriptions** (lines 5-6): Both transaction channels are subscribed. +* **Announcement** (line 7): The transaction is announced and its hash is printed. +* **Transaction flow** (lines 8-9): The transaction moves from `unconfirmed` to `confirmed`, showing the confirmation + lifecycle. +* **Confirmation** (line 10): The hash from the `/transactions/{address}` channel matches the announced transaction. +* **Unsubscribe** (line 11): The code unsubscribes from both channels. + +## Conclusion + +This tutorial showed how to: + +| Step | Related documentation | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [Register the account](#registering-the-account) | , | +| [Subscribe to the unconfirmed channel](#subscribing-to-the-channels) | | +| [Subscribe to the transactions channel](#subscribing-to-the-channels) | | +| [Handle transaction messages](#announcing-and-waiting-for-confirmation) | [TransactionMetaDataPair](../reference/rest/nem.md#model/TransactionMetaDataPair) | diff --git a/mkdocs/scripts/hooks.py b/mkdocs/scripts/hooks.py index 3621e86db..1db51d396 100644 --- a/mkdocs/scripts/hooks.py +++ b/mkdocs/scripts/hooks.py @@ -453,6 +453,11 @@ def page_markdown_ws(content, page, config, files): return content +def page_markdown_req(content, page, config, files): + content = re.sub(r'(]*>)', r'\1 REQ', content) + return content + + def page_markdown_tutorial_complexity_tags(content, page, config, files): if 'tutorial_level' in page.meta: level = page.meta['tutorial_level'] @@ -471,6 +476,7 @@ def on_page_markdown(content, page, config, files): content = page_markdown_dylinks(content, page, config, files) content = page_markdown_rest(content, page, config, files) content = page_markdown_ws(content, page, config, files) + content = page_markdown_req(content, page, config, files) content = page_markdown_tutorial_complexity_tags(content, page, config, files) return content diff --git a/mkdocs/snippets/devbook/websockets/listen_new_blocks.log b/mkdocs/snippets/devbook/websockets/listen_new_blocks.log index e8e66a37e..1d7b5b3b5 100644 --- a/mkdocs/snippets/devbook/websockets/listen_new_blocks.log +++ b/mkdocs/snippets/devbook/websockets/listen_new_blocks.log @@ -1,9 +1,9 @@ -Using node http://libertalia.nemtest.net:7778 +Using node http://libertalia.nemtest.net:7890 Connected to http://libertalia.nemtest.net:7778 Subscribed to /blocks channel -New block: height=663,575 harvester=7c814b3b44b1a98b... -New block: height=663,576 harvester=7c814b3b44b1a98b... -New block: height=663,577 harvester=95ba0e864cd5edb3... -New block: height=663,578 harvester=7c814b3b44b1a98b... -New block: height=663,579 harvester=5451f450416d214b... +New block: height=682,352 harvester=95BA0E864CD5EDB3... +New block: height=682,353 harvester=95BA0E864CD5EDB3... +New block: height=682,354 harvester=7C814B3B44B1A98B... +New block: height=682,355 harvester=95BA0E864CD5EDB3... +New block: height=682,356 harvester=95BA0E864CD5EDB3... Unsubscribed and disconnected diff --git a/mkdocs/snippets/devbook/websockets/listen_new_blocks.mjs b/mkdocs/snippets/devbook/websockets/listen_new_blocks.mjs index 797ff276d..75d40b8db 100644 --- a/mkdocs/snippets/devbook/websockets/listen_new_blocks.mjs +++ b/mkdocs/snippets/devbook/websockets/listen_new_blocks.mjs @@ -1,34 +1,36 @@ import { Client } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; -const NODE_URL = process.env.NODE_URL - || 'http://libertalia.nemtest.net:7778'; +const NODE_URL = process.env.NODE_URL || + 'http://libertalia.nemtest.net:7890'; +const WS_URL = NODE_URL.replace(':7890', ':7778'); console.log(`Using node ${NODE_URL}`); // Open connection [>step-1] const client = new Client({ - webSocketFactory: () => new SockJS(`${NODE_URL}/w/messages`) + webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`) }); await new Promise(resolve => { client.onConnect = resolve; client.activate(); }); -console.log(`Connected to ${NODE_URL}`); +console.log(`Connected to ${WS_URL}`); // [step-3] function formatBlock(message) { const block = JSON.parse(message.body); console.log( `New block: height=${block.height.toLocaleString()}` + - ` harvester=${block.signer.substring(0, 16)}...` + ` harvester=${block.signer.substring(0, 16).toUpperCase()}...` ); } // [step-2] -const subscription = client.subscribe('/blocks', formatBlock, { +const destination = '/blocks'; +const subscription = client.subscribe(destination, formatBlock, { id: 'id-0' }); -console.log('Subscribed to /blocks channel'); +console.log(`Subscribed to ${destination} channel`); // [step-4] process.on('SIGINT', () => { diff --git a/mkdocs/snippets/devbook/websockets/listen_new_blocks.py b/mkdocs/snippets/devbook/websockets/listen_new_blocks.py index 843eff624..1d1346867 100644 --- a/mkdocs/snippets/devbook/websockets/listen_new_blocks.py +++ b/mkdocs/snippets/devbook/websockets/listen_new_blocks.py @@ -7,7 +7,8 @@ import stomper from websockets import connect -NODE_URL = os.getenv('NODE_URL', 'http://libertalia.nemtest.net:7778') +NODE_URL = os.getenv('NODE_URL', 'http://libertalia.nemtest.net:7890') +WS_URL = NODE_URL.replace(':7890', ':7778') print(f'Using node {NODE_URL}') @@ -29,7 +30,7 @@ async def send_frame(websocket, frame): async def stomp_connect(websocket): await websocket.recv() # consume the SockJS open frame await send_frame( - websocket, stomper.connect('', '', NODE_URL, heartbeats=(0, 0))) + websocket, stomper.connect('', '', WS_URL, heartbeats=(0, 0))) async def stomp_subscribe(websocket, destination, sub_id): @@ -44,11 +45,11 @@ async def stomp_disconnect(websocket): await send_frame(websocket, stomper.disconnect()) -def stomp_messages(raw): +def stomp_messages(raw_frame): # Yield the JSON body of each STOMP MESSAGE in a SockJS data frame - if 'a' != raw[0]: # skip 'o' open, 'h' heartbeat, 'c' close + if 'a' != raw_frame[0]: # skip 'o' open, 'h' heartbeat, 'c' close return - for payload in json.loads(raw[1:]): + for payload in json.loads(raw_frame[1:]): frame = stomper.unpack_frame(payload) if 'MESSAGE' == frame['cmd']: yield json.loads(frame['body']) @@ -56,21 +57,22 @@ def stomp_messages(raw): async def main(): # Open connection [>step-1] - async with connect(sockjs_url(f'{NODE_URL}/w/messages')) as websocket: + async with connect(sockjs_url(f'{WS_URL}/w/messages')) as websocket: await stomp_connect(websocket) - print(f'Connected to {NODE_URL}') + print(f'Connected to {WS_URL}') # [step-2] - await stomp_subscribe(websocket, '/blocks', 'id-0') - print('Subscribed to /blocks channel') + destination = '/blocks' + await stomp_subscribe(websocket, destination, 'id-0') + print(f'Subscribed to {destination} channel') # [step-3] try: - async for raw in websocket: - for block in stomp_messages(raw): + async for raw_frame in websocket: + for block in stomp_messages(raw_frame): print( f'New block: height={block["height"]:,}' - f' harvester={block["signer"][:16]}...' + f' harvester={block["signer"][:16].upper()}...' ) # [step-4] diff --git a/mkdocs/snippets/devbook/websockets/listen_transaction_flow.log b/mkdocs/snippets/devbook/websockets/listen_transaction_flow.log new file mode 100644 index 000000000..65f14d79a --- /dev/null +++ b/mkdocs/snippets/devbook/websockets/listen_transaction_flow.log @@ -0,0 +1,11 @@ +Using node http://libertalia.nemtest.net:7890 +Monitoring address: TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 +Connected to http://libertalia.nemtest.net:7778 +Account registered +Subscribed to /unconfirmed/TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 channel +Subscribed to /transactions/TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 channel +Announcing transaction 2928C2D9554AE127... +unconfirmed: hash=2928c2d9554ae127... +confirmed: hash=2928c2d9554ae127... +Transaction 2928C2D9554AE127... confirmed +Unsubscribed from all channels diff --git a/mkdocs/snippets/devbook/websockets/listen_transaction_flow.mjs b/mkdocs/snippets/devbook/websockets/listen_transaction_flow.mjs new file mode 100644 index 000000000..032fdde6e --- /dev/null +++ b/mkdocs/snippets/devbook/websockets/listen_transaction_flow.mjs @@ -0,0 +1,118 @@ +import { Client } from '@stomp/stompjs'; +import SockJS from 'sockjs-client'; +import { PrivateKey } from 'symbol-sdk'; +import { + NemFacade, calculateTransactionFee, models +} from 'symbol-sdk/nem'; + +const NODE_URL = process.env.NODE_URL || + 'http://libertalia.nemtest.net:7890'; +const WS_URL = NODE_URL.replace(':7890', ':7778'); +console.log(`Using node ${NODE_URL}`); +// Set up the monitored address and signer [>step-1] +const MONITOR_ADDRESS = process.env.MONITOR_ADDRESS || + 'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4'; +console.log(`Monitoring address: ${MONITOR_ADDRESS}`); + +const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY || + '0000000000000000000000000000000000000000000000000000000000000000'; +const facade = new NemFacade('testnet'); +const signerKeyPair = new NemFacade.KeyPair( + new PrivateKey(SIGNER_PRIVATE_KEY)); // [step-2] + const client = new Client({ + webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`) + }); + await new Promise(resolve => { + client.onConnect = resolve; + client.activate(); + }); + console.log(`Connected to ${WS_URL}`); + // [step-3] + const destination = `/account/${MONITOR_ADDRESS}`; + await new Promise(resolve => { + client.subscribe(destination, () => { + client.unsubscribe('id-0'); + resolve(); + }, { id: 'id-0' }); + client.publish({ + destination: '/w/api/account/get', + body: JSON.stringify({ account: MONITOR_ADDRESS }) + }); + }); + console.log('Account registered'); + // [step-4] + const channels = { + [`/unconfirmed/${MONITOR_ADDRESS}`]: 'id-1', + [`/transactions/${MONITOR_ADDRESS}`]: 'id-2' + }; + // The message handler is set later, when waiting for confirmation + let handleMessage; + const onMessage = message => handleMessage?.(message); + for (const [channel, id] of Object.entries(channels)) { + client.subscribe(channel, onMessage, { id }); + console.log(`Subscribed to ${channel} channel`); + } + // [step-5] + const timeResponse = await fetch( + `${NODE_URL}/time-sync/network-time`); + const networkTime = Math.floor( + (await timeResponse.json()).receiveTimeStamp / 1000); + const transaction = facade.transactionFactory.create({ + type: 'transfer_transaction_v2', + signerPublicKey: signerKeyPair.publicKey.toString(), + timestamp: networkTime, + deadline: networkTime + (2 * 60 * 60), + recipientAddress: MONITOR_ADDRESS, + amount: 0n + }); + transaction.fee = new models.Amount( + calculateTransactionFee(transaction)); + const signature = facade.signTransaction(signerKeyPair, transaction); + const jsonPayload = facade.transactionFactory.static.attachSignature( + transaction, signature); + const transactionHash = + facade.hashTransaction(transaction).toString().toUpperCase(); + // [step-6] + const shortHash = transactionHash.substring(0, 16); + const confirmed = new Promise(resolve => { + handleMessage = message => { + const pair = JSON.parse(message.body); + const messageHash = pair.meta.hash.data; + const messageShort = messageHash.substring(0, 16); + const status = message.headers.destination + .includes('/transactions/') ? 'confirmed' : 'unconfirmed'; + console.log(`${status}: hash=${messageShort}...`); + if ('confirmed' === status && + messageHash.toUpperCase() === transactionHash) { + console.log(`Transaction ${shortHash}... confirmed`); + resolve(); + } + }; + }); + console.log(`Announcing transaction ${shortHash}...`); + const response = await fetch(`${NODE_URL}/transaction/announce`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonPayload + }); + const announceResult = await response.json(); + if ('SUCCESS' === announceResult.message) + await confirmed; + else + console.log(`Transaction rejected: ${announceResult.message}`); + // [step-7] + for (const id of Object.values(channels)) + client.unsubscribe(id); + console.log('Unsubscribed from all channels'); + client.deactivate(); // [step-1] +MONITOR_ADDRESS = os.getenv( + 'MONITOR_ADDRESS', + 'TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4' +) +print(f'Monitoring address: {MONITOR_ADDRESS}') + +SIGNER_PRIVATE_KEY = os.getenv( + 'SIGNER_PRIVATE_KEY', + '0000000000000000000000000000000000000000000000000000000000000000' +) +facade = NemFacade('testnet') +signer_key_pair = NemFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY)) # [step-2] + endpoint = f'{WS_URL}/w/messages' + async with connect(sockjs_url(endpoint)) as websocket: + await stomp_connect(websocket) + print(f'Connected to {WS_URL}') + # [step-3] + destination = f'/account/{MONITOR_ADDRESS}' + await stomp_subscribe(websocket, destination, 'id-0') + await stomp_send(websocket, '/w/api/account/get', + json.dumps({'account': MONITOR_ADDRESS})) + async for raw_frame in websocket: + if any(f['headers']['destination'] == destination + for f in stomp_messages(raw_frame)): + break + await stomp_unsubscribe(websocket, 'id-0') + print('Account registered') + # [step-4] + channels = { + f'/unconfirmed/{MONITOR_ADDRESS}': 'id-1', + f'/transactions/{MONITOR_ADDRESS}': 'id-2', + } + for channel, sub_id in channels.items(): + await stomp_subscribe(websocket, channel, sub_id) + print(f'Subscribed to {channel} channel') + # [step-5] + with urllib.request.urlopen( + f'{NODE_URL}/time-sync/network-time' + ) as resp: + network_time = json.loads( + resp.read().decode())['receiveTimeStamp'] // 1000 + transaction = facade.transaction_factory.create({ + 'type': 'transfer_transaction_v2', + 'signer_public_key': signer_key_pair.public_key, + 'timestamp': network_time, + 'deadline': network_time + 2 * 60 * 60, + 'recipient_address': MONITOR_ADDRESS, + 'amount': 0, + }) + transaction.fee = Amount(calculate_transaction_fee(transaction)) + signature = facade.sign_transaction(signer_key_pair, transaction) + json_payload = facade.transaction_factory.attach_signature( + transaction, signature) + transaction_hash = str( + facade.hash_transaction(transaction)).upper() + # [step-6] + print(f'Announcing transaction {transaction_hash[:16]}...') + announce_request = urllib.request.Request( + f'{NODE_URL}/transaction/announce', + data=json_payload.encode(), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + with urllib.request.urlopen(announce_request) as resp: + result = json.loads(resp.read().decode()) + + if 'SUCCESS' == result['message']: + confirmed = False + async for raw_frame in websocket: + for frame in stomp_messages(raw_frame): + destination = frame['headers']['destination'] + pair = json.loads(frame['body']) + message_hash = pair['meta']['hash']['data'] + status = ( + 'confirmed' if '/transactions/' in destination + else 'unconfirmed') + print(f'{status}: hash={message_hash[:16]}...') + is_match = message_hash.upper() == transaction_hash + if status == 'confirmed' and is_match: + short_hash = transaction_hash[:16] + print(f'Transaction {short_hash}... confirmed') + confirmed = True + if confirmed: + break + else: + print(f'Transaction rejected: {result["message"]}') + # [step-7] + for sub_id in channels.values(): + await stomp_unsubscribe(websocket, sub_id) + print('Unsubscribed from all channels') + await stomp_disconnect(websocket) # [