Bonded aggregate transactions follow a richer lifecycle than regular transactions.
After being announced, they enter a partial state where the network receives cosignatures from all required
participants.
Only after all cosignatures arrive does the transaction move through the standard unconfirmed and confirmed states.
This tutorial recreates the asset swap from the
Bonded Aggregate Transaction tutorial, but monitors the full bonded lifecycle
using WebSocket channels instead of polling.
Account A builds and announces the aggregate, while Account B subscribes to WebSocket channels, cosigns, and waits
for confirmation.
Additionally, install the language-specific WebSocket library:
Install the websockets library:
pipinstallwebsockets
This tutorial uses the native WebSocket API available in Node.js 22 or later.
No additional packages are required.
You also need two accounts with XYM and one custom mosaic to complete the swap.
Although pre-funded accounts are provided for convenience, they are not maintained and may run out of funds.
To use your own accounts, complete the following steps:
Create an account (Account A) to initiate the aggregate transaction, either
from code or
by using a wallet.
Create a second account (Account B) to participate in the swap.
A bonded aggregate transaction involves two distinct roles: an initiator (Account A) that builds, signs, and
announces the aggregate, and one or more cosigners (Account B and any additional cosigners) that monitor
WebSocket channels and cosign after verifying the transaction.
In practice, each role runs as a separate program on a separate machine, and all cosigners must already be
listening before the initiator submits the bonded aggregate.
This tutorial combines both roles in a single script for simplicity.
This example includes both private keys in one script for simplicity.
In practice, each party signs on their own machine.
Account A only needs Account B's public key to build the aggregate, because B's public key is required to set B as
the signer of an embedded transaction and to derive B's address.
The ACCOUNT_A_PRIVATE_KEY and ACCOUNT_B_PRIVATE_KEY environment variables set the keys for each account.
If not provided, test keys are used by default.
If using your own keys, ensure Account A has XYM and Account B holds a custom mosaic for the swap.
The addresses are derived from the public keys using the facade's network configuration.
Account A: Building the Aggregate and Announcing the Hash Lock⚓︎
// [Account A] Build embedded transactions for the swapconstembeddedTx1=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountAKeyPair.publicKey.toString(),recipientAddress:accountBAddress.toString(),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:10_000_000n}]});constcustomMosaicId=0x6D1314BE751B62C2n;constembeddedTx2=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountBKeyPair.publicKey.toString(),recipientAddress:accountAAddress.toString(),mosaics:[{mosaicId:customMosaicId,amount:1n}]});// Build the bonded aggregate transactionconstembeddedTxs=[embeddedTx1,embeddedTx2];constbondedTx=facade.transactionFactory.create({type:'aggregate_bonded_transaction_v3',signerPublicKey:accountAKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(embeddedTxs),transactions:embeddedTxs});bondedTx.fee=newmodels.Amount(feeMultiplier*(bondedTx.size+104));// Sign the bonded aggregateconstbondedSignature=facade.signTransaction(accountAKeyPair,bondedTx);constbondedPayload=facade.transactionFactory.static.attachSignature(bondedTx,bondedSignature);constbondedHash=facade.hashTransaction(bondedTx).toString();console.log('[Account A] Bonded aggregate hash: '+`${bondedHash.substring(0,16)}...`);// Create the hash lock transactionconsthashLock=facade.transactionFactory.create({type:'hash_lock_transaction_v1',signerPublicKey:accountAKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,mosaic:{mosaicId:generateMosaicAliasId('symbol.xym'),amount:10_000_000n},duration:100n,hash:bondedHash});hashLock.fee=newmodels.Amount(feeMultiplier*hashLock.size);consthashLockSignature=facade.signTransaction(accountAKeyPair,hashLock);consthashLockPayload=facade.transactionFactory.static.attachSignature(hashLock,hashLockSignature);consthashLockHash=facade.hashTransaction(hashLock).toString();// Confirm hash lock via WebSocketconstlockWebSocket=newWebSocket(WS_URL);constlockUid=awaitnewPromise((resolve)=>{lockWebSocket.addEventListener('message',(event)=>{constmessage=JSON.parse(event.data);resolve(message.uid);},{once:true});});constaddressA=accountAAddress.toString();constlockChannels=[`confirmedAdded/${addressA}`,`status/${addressA}`,];for(constchanneloflockChannels){lockWebSocket.send(JSON.stringify({uid:lockUid,subscribe:channel}));}// Announce hash lockawaitfetch(`${NODE_URL}/transactions`,{method:'PUT',headers:{'Content-Type':'application/json'},body:hashLockPayload});console.log('[Account A] Announced hash lock '+`${hashLockHash.substring(0,16)}...`);// Wait for hash lock confirmationawaitnewPromise((resolve,reject)=>{lockWebSocket.addEventListener('message',(event)=>{constmessage=JSON.parse(event.data);constname=message.topic.split('/')[0];if(name==='confirmedAdded'&&message.data.meta.hash===hashLockHash){console.log('Hash lock confirmed');resolve();}if(name==='status'&&message.data.hash===hashLockHash){reject(newError('Hash lock failed: '+message.data.code));}});});for(constchanneloflockChannels){lockWebSocket.send(JSON.stringify({uid:lockUid,unsubscribe:channel}));}lockWebSocket.close();
Account A creates the bonded aggregate that swaps 10 XYM for 1 custom mosaic from Account B, signs it, and announces the
required hash lock, following the same pattern described in the
Bonded Aggregate Transaction tutorial.
# [Account B] Connect to WebSocket for bonded flowasyncwithconnect(WS_URL)aswebsocket:response=json.loads(awaitwebsocket.recv())uid=response['uid']print(f'[Account B] Connected to {WS_URL} with uid {uid}')# Subscribe to bonded transaction channelschannels=[f'partialAdded/{account_b_address}',f'partialRemoved/{account_b_address}',f'cosignature/{account_b_address}',f'unconfirmedAdded/{account_b_address}',f'unconfirmedRemoved/{account_b_address}',f'confirmedAdded/{account_b_address}',f'status/{account_b_address}',]forchannelinchannels:awaitwebsocket.send(json.dumps({'uid':uid,'subscribe':channel}))name=channel.split('/')[0]print(f'[Account B] Subscribed to {name} channel')
// [Account B] Connect to WebSocket for bonded flowconstwebsocket=newWebSocket(WS_URL);constuid=awaitnewPromise((resolve)=>{websocket.addEventListener('message',(event)=>{constmessage=JSON.parse(event.data);resolve(message.uid);},{once:true});});console.log(`[Account B] Connected to ${WS_URL} with uid ${uid}`);// Subscribe to bonded transaction channelsconstaddressB=accountBAddress.toString();constchannels=[`partialAdded/${addressB}`,`partialRemoved/${addressB}`,`cosignature/${addressB}`,`unconfirmedAdded/${addressB}`,`unconfirmedRemoved/${addressB}`,`confirmedAdded/${addressB}`,`status/${addressB}`,];for(constchannelofchannels){websocket.send(JSON.stringify({uid,subscribe:channel}));constname=channel.split('/')[0];console.log(`[Account B] Subscribed to ${name} channel`);}
The snippet uses the NODE_URL environment variable to set the Symbol API node.
If no value is provided, a default one is used.
The WebSocket URL is derived from NODE_URL by replacing the HTTP protocol with the WebSocket protocol and appending
/ws.
Account B opens a WebSocket connection and subscribes to channels scoped to its own address to monitor the bonded
transaction lifecycle.
Since Account B is a participant in the aggregate, the node delivers all lifecycle events for the transaction to
Account B's address.
In addition to the channels used for regular transactions, bonded aggregates
use extra channels:
partialAdded/{address}WS: Notifies when a bonded aggregate enters the partial state, waiting for
cosignatures.
partialRemoved/{address}WS: Notifies when a bonded aggregate leaves the partial state (either all
cosignatures were collected or the deadline expired).
cosignature/{address}WS: Notifies when a cosignature is added to a partial transaction.
// [Account A] Announce bonded aggregateawaitfetch(`${NODE_URL}/transactions/partial`,{method:'PUT',headers:{'Content-Type':'application/json'},body:bondedPayload});console.log('[Account A] Announced bonded '+`${bondedHash.substring(0,16)}...`);// Wait for confirmation via WebSocketawaitconfirmed;
Account B listens for incoming messages and dispatches them by channel.
The message schemas are the same as in the regular transaction flow tutorial,
except for cosignature messages, which follow the
CosignatureDTO schema
and do not include the meta.hash field used by other channels.
The key action happens on partialAdded: when the hash matches the expected aggregate,
Account B cosigns the transaction using with the detached
parameter set to true, and announces the cosignature to /transactions/cosignaturePUT.
For deeper verification, Account B can fetch the full transaction from
/transactions/partial/{transactionId}GET and inspect its contents before deciding whether to cosign.
The expected message sequence for a successful bonded aggregate is described in the
Transaction Lifecycle section:
partialAdded: The bonded aggregate enters the partial cache, waiting for cosignatures.
cosignature: A cosignature from Account B is added.
unconfirmedAdded: The fully signed transaction enters the unconfirmed pool.
partialRemoved: The transaction leaves the partial state.
unconfirmedRemoved: The transaction leaves the unconfirmed pool.
confirmedAdded: The transaction is confirmed in a block.
# Unsubscribe before closingforchannelinchannels:awaitwebsocket.send(json.dumps({'uid':uid,'unsubscribe':channel}))print('[Account B] Unsubscribed from all channels')
// Unsubscribe before closingfor(constchannelofchannels){websocket.send(JSON.stringify({uid,unsubscribe:channel}));}console.log('[Account B] Unsubscribed from all channels');websocket.close();
After confirmation, Account B sends unsubscribe messages for all channels before closing the connection.
Using node https://reference.symboltest.net:3001
Account A: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Account B: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI
[Account A] Bonded aggregate hash: 1B48628680C99C5F...
[Account A] Announced hash lock A9CD8DE6E4CD1EDF...
Hash lock confirmed
[Account B] Connected to wss://reference.symboltest.net:3001/ws with uid bPpqa5v2vhja9J5wUTjP8OIc5Io=
[Account B] Subscribed to partialAdded channel
[Account B] Subscribed to partialRemoved channel
[Account B] Subscribed to cosignature channel
[Account B] Subscribed to unconfirmedAdded channel
[Account B] Subscribed to unconfirmedRemoved channel
[Account B] Subscribed to confirmedAdded channel
[Account B] Subscribed to status channel
[Account A] Announced bonded 1B48628680C99C5F...
partialAdded: hash=1B48628680C99C5F...
[Account B] Submitted cosignature
cosignature: signer=D04AB232742BB4AB...
unconfirmedAdded: hash=1B48628680C99C5F...
partialRemoved: hash=1B48628680C99C5F...
unconfirmedRemoved: hash=1B48628680C99C5F...
confirmedAdded: hash=1B48628680C99C5F...
Transaction 1B48628680C99C5F... confirmed
[Account B] Unsubscribed from all channels
The output shows:
Accounts (lines 2-3): The addresses for Account A (initiator) and Account B (cosigner).
Hash lock (lines 6): The bonded aggregate hash is computed, the hash lock is announced, and its confirmation
is received via WebSocket.
Connection (line 7): The WebSocket connection is established and the server returns a unique uid.
Subscriptions (lines 8-14): All seven bonded transaction channels are subscribed (including status).
Announcement (line 15): The bonded aggregate is announced to /transactions/partial.
Cosigning (lines 16-18): The aggregate enters partialAdded, Account B submits a cosignature,
and the cosignature channel confirms it was received.
Confirmation (lines 19-22): The fully signed transaction enters the unconfirmed pool (unconfirmedAdded),
leaves the partial state (partialRemoved), moves through unconfirmedRemoved, and is finally confirmedAdded.