A transaction from a multisignature account follows a richer lifecycle than a regular transaction.
After being announced, it waits in the unconfirmed pool while the network collects the required cosignatures from
the account's cosignatories.
Only after all cosignatures arrive is the transaction confirmed in a block.
The multisig account used in this tutorial is configured as a 2-of-2 multisig.
It has two cosignatories, and both signatures are required to approve a transaction:
Cosignatory 0 builds and announces the multisig transaction, while Cosignatory 1 subscribes to the multisig
account's WebSocket channels, cosigns, and waits for confirmation.
The multisig configured in that tutorial is a 1-of-2, where a single cosignatory signature is enough.
This tutorial instead requires the stricter 2-of-2 configuration described above.
After running this tutorial, remember that the configure tutorial's default disable path assumes a 1-of-2
multisig.
To disable the 2-of-2 multisig, remove cosignatory 1 first with min_approval_delta set to -1, and have
both cosignatories sign that removal.
Once confirmed, remove cosignatory 0 with min_approval_delta set to -1 as usual.
Additionally, NEM serves WebSockets using the STOMP messaging protocol over
SockJS, so a STOMP client and a WebSocket transport are required:
Install the stomper and websockets libraries:
pipinstallstomperwebsockets
Install the @stomp/stompjs and sockjs-client libraries:
importasyncioimportjsonimportosimportrandomimporturllib.requestimportuuidimportstomperfromsymbolchain.CryptoTypesimportPrivateKey,PublicKeyfromsymbolchain.facade.NemFacadeimportNemFacadefromsymbolchain.ncimportAmountfromsymbolchain.nem.FeeCalculatorimportcalculate_transaction_feefromsymbolchain.nem.NetworkimportNetworkTimestampfromwebsocketsimportconnectNODE_URL=os.getenv('NODE_URL','http://libertalia.nemtest.net:7890')WS_URL=NODE_URL.replace(':7890',':7778')print(f'Using node {NODE_URL}')# SockJS has no Python client library.# These helpers wrap the raw WebSocket transport to mirror a STOMP client.defsockjs_url(endpoint_url):# SockJS raw WebSocket transport adds a random server and session idserver=random.randint(100,999)session=uuid.uuid4().hexws_base=endpoint_url.replace('http','ws',1)returnf'{ws_base}/{server}/{session}/websocket'asyncdefsend_frame(websocket,frame):# SockJS wraps each client payload as a JSON array of frame stringsawaitwebsocket.send(json.dumps([frame]))asyncdefstomp_connect(websocket):awaitwebsocket.recv()# consume the SockJS open frameawaitsend_frame(websocket,stomper.connect('','',NODE_URL,heartbeats=(0,0)))asyncdefstomp_subscribe(websocket,destination,sub_id):awaitsend_frame(websocket,stomper.subscribe(destination,sub_id))asyncdefstomp_send(websocket,destination,body):awaitsend_frame(websocket,stomper.send(destination,body))asyncdefstomp_unsubscribe(websocket,sub_id):awaitsend_frame(websocket,stomper.unsubscribe(sub_id))asyncdefstomp_disconnect(websocket):awaitsend_frame(websocket,stomper.disconnect())defstomp_messages(raw_frame):# Yield each STOMP MESSAGE frame in a SockJS data frameif'a'!=raw_frame[0]:# skip 'o' open, 'h' heartbeat, 'c' closereturnforpayloadinjson.loads(raw_frame[1:]):frame=stomper.unpack_frame(payload)if'MESSAGE'==frame['cmd']:yieldframeasyncdefstomp_frames(websocket):# Yield each STOMP MESSAGE frame as it arrivesasyncforraw_frameinwebsocket:forframeinstomp_messages(raw_frame):yieldframefacade=NemFacade('testnet')# Set up the multisig and cosignatory accountsMULTISIG_PUBLIC_KEY=os.getenv('MULTISIG_PUBLIC_KEY','D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')multisig_public_key=PublicKey(MULTISIG_PUBLIC_KEY)multisig_address=str(facade.network.public_key_to_address(multisig_public_key))print(f'Multisig address: {multisig_address}')COSIGNATORY0_PRIVATE_KEY=os.getenv('COSIGNATORY0_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000002')cosignatory0_key_pair=NemFacade.KeyPair(PrivateKey(COSIGNATORY0_PRIVATE_KEY))print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')COSIGNATORY1_PRIVATE_KEY=os.getenv('COSIGNATORY1_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000003')cosignatory1_key_pair=NemFacade.KeyPair(PrivateKey(COSIGNATORY1_PRIVATE_KEY))print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')asyncdefmain():# [Cosignatory 0] Build and sign the multisig transactionwithurllib.request.urlopen(f'{NODE_URL}/time-sync/network-time')asresp:network_time=json.loads(resp.read().decode())['receiveTimeStamp']//1000timestamp=NetworkTimestamp(network_time)deadline=timestamp.add_hours(2)transfer_transaction=facade.transaction_factory.create({'type':'transfer_transaction_v2','signer_public_key':multisig_public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,'recipient_address':multisig_address,'amount':1_000_000# 1 XEM})transfer_transaction.fee=Amount(calculate_transaction_fee(transfer_transaction))transaction=facade.transaction_factory.create({'type':'multisig_transaction_v1','signer_public_key':cosignatory0_key_pair.public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,'inner_transaction':facade.transaction_factory.to_non_verifiable_transaction(transfer_transaction)})transaction.fee=Amount(calculate_transaction_fee(transaction))signature=facade.sign_transaction(cosignatory0_key_pair,transaction)json_payload=facade.transaction_factory.attach_signature(transaction,signature)transaction_hash=str(facade.hash_transaction(transaction)).upper()print('[Cosignatory 0] Built multisig transaction 'f'{transaction_hash[:16]}...')# [Cosignatory 1] Connect to the WebSocketendpoint=f'{WS_URL}/w/messages'asyncwithconnect(sockjs_url(endpoint))aswebsocket:awaitstomp_connect(websocket)print(f'[Cosignatory 1] Connected to {WS_URL}')frames=stomp_frames(websocket)# [Cosignatory 1] Subscribe to the multisig account channelsaccount_channel=f'/account/{multisig_address}'channels={account_channel:'id-0',f'/unconfirmed/{multisig_address}':'id-1',f'/transactions/{multisig_address}':'id-2',}forchannel,sub_idinchannels.items():awaitstomp_subscribe(websocket,channel,sub_id)print(f'[Cosignatory 1] Subscribed to {channel} channel')# [Cosignatory 1] Register the multisig accountawaitstomp_send(websocket,'/w/api/account/get',json.dumps({'account':multisig_address}))asyncforframeinframes:ifaccount_channel==frame['headers']['destination']:balance=json.loads(frame['body'])['account']['balance']print(f'Account update: balance={balance}')breakprint('[Cosignatory 1] Multisig account registered')# [Cosignatory 0] Announce the multisig transactionprint('[Cosignatory 0] Announcing multisig transaction 'f'{transaction_hash[:16]}...')announce_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(announce_request)asresp:result=json.loads(resp.read().decode())if'SUCCESS'!=result['message']:print(f'Transaction rejected: {result["message"]}')return# The transaction is now waiting for the second signature# [Cosignatory 1] Select the pending multisig transactioninner_transaction_hash=Noneasyncforframeinframes:destination=frame['headers']['destination']body=json.loads(frame['body'])if'/unconfirmed/'notindestination:continuesigner=body['transaction'].get('otherTrans',{}).get('signer','')ifsigner.upper()!=str(multisig_public_key):continueinner_transaction_hash=body['meta']['innerHash']['data']print('unconfirmed: innerHash='f'{inner_transaction_hash[:16]}...')# [Cosignatory 1] Cosign the pending transactioncosignature=facade.transaction_factory.create({'type':'cosignature_v1',# This is the cosignatory providing the second signature'signer_public_key':cosignatory1_key_pair.public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,# Hash of the inner transfer transaction'other_transaction_hash':inner_transaction_hash,# Address of the multisig account'multisig_account_address':multisig_address})cosignature.fee=Amount(calculate_transaction_fee(cosignature))cosignature_signature=facade.sign_transaction(cosignatory1_key_pair,cosignature)cosignature_payload=(facade.transaction_factory.attach_signature(cosignature,cosignature_signature))cosignature_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=cosignature_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(cosignature_request)asresp:cosignature_result=json.loads(resp.read().decode())if'SUCCESS'!=cosignature_result['message']:print('Cosignature rejected: 'f'{cosignature_result["message"]}')returnprint('[Cosignatory 1] Announced cosignature')break# [Cosignatory 1] Wait for confirmationconfirmed=Falseasyncforframeinframes:destination=frame['headers']['destination']body=json.loads(frame['body'])ifaccount_channel==destination:balance=body['account']['balance']print(f'Account update: balance={balance}')ifconfirmed:breakelif'/transactions/'indestination:message_hash=body['meta']['innerHash']['data']print(f'confirmed: innerHash={message_hash[:16]}...')matched=message_hash==inner_transaction_hashifmatchedandnotconfirmed:print('Multisig transaction confirmed')confirmed=True# [Cosignatory 1] Unsubscribe before closingforsub_idinchannels.values():awaitstomp_unsubscribe(websocket,sub_id)print('[Cosignatory 1] Unsubscribed from all channels')awaitstomp_disconnect(websocket)try:asyncio.run(main())exceptExceptionaserror:print(error)
import{Client}from'@stomp/stompjs';importSockJSfrom'sockjs-client';import{PrivateKey,PublicKey}from'symbol-sdk';import{NemFacade,NetworkTimestamp,calculateTransactionFee,models}from'symbol-sdk/nem';constNODE_URL=process.env.NODE_URL||'http://libertalia.nemtest.net:7890';constWS_URL=NODE_URL.replace(':7890',':7778');console.log(`Using node ${NODE_URL}`);constfacade=newNemFacade('testnet');// Set up the multisig and cosignatory accountsconstMULTISIG_PUBLIC_KEY=process.env.MULTISIG_PUBLIC_KEY||('D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');constmultisigPublicKey=newPublicKey(MULTISIG_PUBLIC_KEY);constmultisigAddress=facade.network.publicKeyToAddress(multisigPublicKey).toString();console.log(`Multisig address: ${multisigAddress}`);constCOSIGNATORY0_PRIVATE_KEY=process.env.COSIGNATORY0_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000002');constcosignatory0KeyPair=newNemFacade.KeyPair(newPrivateKey(COSIGNATORY0_PRIVATE_KEY));console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);constCOSIGNATORY1_PRIVATE_KEY=process.env.COSIGNATORY1_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000003');constcosignatory1KeyPair=newNemFacade.KeyPair(newPrivateKey(COSIGNATORY1_PRIVATE_KEY));console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);try{// [Cosignatory 0] Build and sign the multisig transactionconsttimeResponse=awaitfetch(`${NODE_URL}/time-sync/network-time`);constnetworkTime=Math.floor((awaittimeResponse.json()).receiveTimeStamp/1000);consttimestamp=newNetworkTimestamp(networkTime);constdeadline=timestamp.addHours(2);consttransferTransaction=facade.transactionFactory.create({type:'transfer_transaction_v2',signerPublicKey:multisigPublicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,recipientAddress:multisigAddress,amount:1_000_000n// 1 XEM});transferTransaction.fee=newmodels.Amount(calculateTransactionFee(transferTransaction));consttransaction=facade.transactionFactory.create({type:'multisig_transaction_v1',signerPublicKey:cosignatory0KeyPair.publicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,innerTransaction:facade.transactionFactory.static.toNonVerifiableTransaction(transferTransaction)});transaction.fee=newmodels.Amount(calculateTransactionFee(transaction));constsignature=facade.signTransaction(cosignatory0KeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);consttransactionHash=facade.hashTransaction(transaction).toString().toUpperCase();constshortHash=transactionHash.substring(0,16);console.log(`[Cosignatory 0] Built multisig transaction ${shortHash}...`);// [Cosignatory 1] Connect to the WebSocketconstclient=newClient({webSocketFactory:()=>newSockJS(`${WS_URL}/w/messages`)});awaitnewPromise(resolve=>{client.onConnect=resolve;client.activate();});console.log(`[Cosignatory 1] Connected to ${WS_URL}`);// [Cosignatory 1] Select the pending multisig transactionletinnerTransactionHash=null;letresolveCosigned;constcosigned=newPromise(resolve=>{resolveCosigned=resolve;});constonUnconfirmed=asyncmessage=>{if(null!==innerTransactionHash)return;constbody=JSON.parse(message.body);constsigner=(body.transaction.otherTrans?.signer??'').toUpperCase();if(multisigPublicKey.toString()!==signer)return;innerTransactionHash=body.meta.innerHash.data;console.log('unconfirmed: innerHash='+`${innerTransactionHash.substring(0,16)}...`);// [Cosignatory 1] Cosign the pending transactionconstcosignature=facade.transactionFactory.create({type:'cosignature_v1',// This is the cosignatory providing the second signaturesignerPublicKey:cosignatory1KeyPair.publicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,// Hash of the inner transfer transactionotherTransactionHash:innerTransactionHash,// Address of the multisig accountmultisigAccountAddress:multisigAddress});cosignature.fee=newmodels.Amount(calculateTransactionFee(cosignature));constcosignatureSignature=facade.signTransaction(cosignatory1KeyPair,cosignature);constcosignaturePayload=facade.transactionFactory.static.attachSignature(cosignature,cosignatureSignature);constcosignatureResponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:cosignaturePayload});constcosignatureResult=awaitcosignatureResponse.json();if('SUCCESS'!==cosignatureResult.message){console.log(`Cosignature rejected: ${cosignatureResult.message}`);resolveCosigned(false);return;}console.log('[Cosignatory 1] Announced cosignature');resolveCosigned(true);};// [Cosignatory 1] Wait for confirmationletconfirmed=false;letresolveRegistered;letresolveDone;constregistered=newPromise(resolve=>{resolveRegistered=resolve;});constdone=newPromise(resolve=>{resolveDone=resolve;});constonConfirmed=message=>{constmessageHash=JSON.parse(message.body).meta.innerHash.data;console.log(`confirmed: innerHash=${messageHash.substring(0,16)}...`);if(messageHash===innerTransactionHash&&!confirmed){console.log('Multisig transaction confirmed');confirmed=true;}};constonAccountUpdate=message=>{const{balance}=JSON.parse(message.body).account;console.log(`Account update: balance=${balance}`);resolveRegistered();if(confirmed)resolveDone();};// [Cosignatory 1] Subscribe to the multisig account channelsconstaccountChannel=`/account/${multisigAddress}`;constsubscriptions=[{channel:accountChannel,handler:onAccountUpdate,id:'id-0'},{channel:`/unconfirmed/${multisigAddress}`,handler:onUnconfirmed,id:'id-1'},{channel:`/transactions/${multisigAddress}`,handler:onConfirmed,id:'id-2'}];for(const{channel,handler,id}ofsubscriptions){client.subscribe(channel,handler,{id});console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);}// [Cosignatory 1] Register the multisig accountclient.publish({destination:'/w/api/account/get',body:JSON.stringify({account:multisigAddress})});awaitregistered;console.log('[Cosignatory 1] Multisig account registered');// [Cosignatory 0] Announce the multisig transactionconsole.log('[Cosignatory 0] Announcing multisig transaction '+`${shortHash}...`);constresponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:jsonPayload});constannounceResult=awaitresponse.json();if('SUCCESS'===announceResult.message){// The transaction is now waiting for the second signature// Wait for the cosignature to be announced and the// transaction to confirmif(awaitcosigned)awaitdone;}else{console.log(`Transaction rejected: ${announceResult.message}`);}// [Cosignatory 1] Unsubscribe before closingfor(const{id}ofsubscriptions)client.unsubscribe(id);console.log('[Cosignatory 1] Unsubscribed from all channels');client.deactivate();}catch(error){console.error(error);}
The snippet uses the NODE_URL environment variable to set the NEM node.
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, the default HTTP API port, with 7778, the default NIS
WebSocket port.
Python SockJS helpers
There is no SockJS client library for Python, so a few small helper methods are defined at the top of the file
for convenience.
A multisig transaction involves two distinct roles: an initiator (Cosignatory 0) that builds, signs, and
announces the multisig transaction, and one or more cosignatories (Cosignatory 1 in this tutorial) that monitor
WebSocket channels and cosign after verifying the transaction.
In practice, each role runs as a separate program on a separate machine, holding only its own private key.
This tutorial combines both roles in a single script for simplicity.
# Set up the multisig and cosignatory accountsMULTISIG_PUBLIC_KEY=os.getenv('MULTISIG_PUBLIC_KEY','D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')multisig_public_key=PublicKey(MULTISIG_PUBLIC_KEY)multisig_address=str(facade.network.public_key_to_address(multisig_public_key))print(f'Multisig address: {multisig_address}')COSIGNATORY0_PRIVATE_KEY=os.getenv('COSIGNATORY0_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000002')cosignatory0_key_pair=NemFacade.KeyPair(PrivateKey(COSIGNATORY0_PRIVATE_KEY))print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')COSIGNATORY1_PRIVATE_KEY=os.getenv('COSIGNATORY1_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000003')cosignatory1_key_pair=NemFacade.KeyPair(PrivateKey(COSIGNATORY1_PRIVATE_KEY))print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')
// Set up the multisig and cosignatory accountsconstMULTISIG_PUBLIC_KEY=process.env.MULTISIG_PUBLIC_KEY||('D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');constmultisigPublicKey=newPublicKey(MULTISIG_PUBLIC_KEY);constmultisigAddress=facade.network.publicKeyToAddress(multisigPublicKey).toString();console.log(`Multisig address: ${multisigAddress}`);constCOSIGNATORY0_PRIVATE_KEY=process.env.COSIGNATORY0_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000002');constcosignatory0KeyPair=newNemFacade.KeyPair(newPrivateKey(COSIGNATORY0_PRIVATE_KEY));console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);constCOSIGNATORY1_PRIVATE_KEY=process.env.COSIGNATORY1_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000003');constcosignatory1KeyPair=newNemFacade.KeyPair(newPrivateKey(COSIGNATORY1_PRIVATE_KEY));console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);
The tutorial requires three separate accounts, configured through environment variables.
If not set, default values are used:
Environment Variable
Default value
Purpose
MULTISIG_PUBLIC_KEY
D656..ACF2
2-of-2 multisig account
COSIGNATORY0_PRIVATE_KEY
0000..0002
First cosignatory account, the initiator
COSIGNATORY1_PRIVATE_KEY
0000..0003
Second cosignatory account
Each key is a 64-character hexadecimal string.
Unlike a regular account, the multisig account cannot initiate transactions itself.
Instead, its cosignatories sign on its behalf.
Its private key is therefore never needed, and its public key is enough to identify the account.
The multisig account must hold enough funds to pay the transaction fees.
If the default values are used, this account may already be funded.
The snippet above derives and stores the key pair of each cosignatory, and the multisig account's address,
for later use.
The WebSocket channels subscribed later are scoped to this address.
Initiator: Building the Multisig Transaction⚓︎
The transaction is prepared, but it is not announced yet.
The announcement happens after the channel subscriptions are established, ensuring that the resulting notifications are
not missed.
# [Cosignatory 1] Connect to the WebSocketendpoint=f'{WS_URL}/w/messages'asyncwithconnect(sockjs_url(endpoint))aswebsocket:awaitstomp_connect(websocket)print(f'[Cosignatory 1] Connected to {WS_URL}')frames=stomp_frames(websocket)
// [Cosignatory 1] Connect to the WebSocketconstclient=newClient({webSocketFactory:()=>newSockJS(`${WS_URL}/w/messages`)});awaitnewPromise(resolve=>{client.onConnect=resolve;client.activate();});console.log(`[Cosignatory 1] Connected to ${WS_URL}`);
Cosignatory 1 opens a SockJS connection to the /w/messages endpoint on WS_URL and starts a STOMP session
over it.
# [Cosignatory 1] Subscribe to the multisig account channelsaccount_channel=f'/account/{multisig_address}'channels={account_channel:'id-0',f'/unconfirmed/{multisig_address}':'id-1',f'/transactions/{multisig_address}':'id-2',}forchannel,sub_idinchannels.items():awaitstomp_subscribe(websocket,channel,sub_id)print(f'[Cosignatory 1] Subscribed to {channel} channel')
// [Cosignatory 1] Subscribe to the multisig account channelsconstaccountChannel=`/account/${multisigAddress}`;constsubscriptions=[{channel:accountChannel,handler:onAccountUpdate,id:'id-0'},{channel:`/unconfirmed/${multisigAddress}`,handler:onUnconfirmed,id:'id-1'},{channel:`/transactions/${multisigAddress}`,handler:onConfirmed,id:'id-2'}];for(const{channel,handler,id}ofsubscriptions){client.subscribe(channel,handler,{id});console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);}
Cosignatory 1 subscribes to the same three address-scoped channels used in the
Listening to Transaction Flow tutorial:
account/{address}WS: Notifies of the account's current state when a block involving the account's address is
confirmed.
unconfirmed/{address}WS: Notifies of a transaction involving the account's address when it enters the
unconfirmed pool, waiting to be included in a block.
transactions/{address}WS: Notifies of a transaction involving the account's address when it is included in a
block.
The subscriptions use the IDs id-0, id-1 and id-2, which identify them when the code unsubscribes at the end.
The difference is the address that each channel is scoped to.
For a pending multisig transaction, the node sends notifications to the initiating cosignatory and to the accounts
involved in the inner transaction.
In this example, the notified accounts are Cosignatory 0, as the initiator, and the multisig account, which is
both the sender and the recipient of the inner transfer.
Other cosignatories, such as Cosignatory 1, do not receive notifications.
As a result, a cosignatory that is waiting to approve transactions must subscribe to the multisig account's address,
not the cosignatory's own address.
Message handling differences
In JavaScript, each channel is subscribed with a dedicated handler function, defined in the
cosigning and
confirmation steps below.
In Python, messages are instead read sequentially from the connection as they arrive.
All three channels stay silent until the address is registered, which the next step performs.
Cosignatory: Registering the Multisig Account⚓︎
To receive notifications on an account's channels, the address must first be registered with the node.
The code sends a request to w/api/account/getREQ, which registers the multisig address and also forces
the node to send the account's current state on the account/{address}WS channel.
The code waits for this first account notification, which confirms that the registration is active.
The notification follows the AccountMetaDataPair schema.
Initiator: Announcing the Multisig Transaction⚓︎
# [Cosignatory 0] Announce the multisig transactionprint('[Cosignatory 0] Announcing multisig transaction 'f'{transaction_hash[:16]}...')announce_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(announce_request)asresp:result=json.loads(resp.read().decode())if'SUCCESS'!=result['message']:print(f'Transaction rejected: {result["message"]}')return# The transaction is now waiting for the second signature
// [Cosignatory 0] Announce the multisig transactionconsole.log('[Cosignatory 0] Announcing multisig transaction '+`${shortHash}...`);constresponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:jsonPayload});constannounceResult=awaitresponse.json();if('SUCCESS'===announceResult.message){// The transaction is now waiting for the second signature
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.
A cosignatory that misses the notification, for example by subscribing only after the announcement, can still
discover the pending transaction by polling /account/unconfirmedTransactionsGET.
Once Cosignatory 1 is subscribed, Cosignatory 0 announces the multisig transaction to the /transaction/announcePOST
endpoint and checks the result.
If the node rejects it, the code prints the rejection reason and stops.
If valid, the network accepts the transaction, but it is not confirmed yet.
Since the multisig account requires two cosignatures and only one has been provided, the transaction waits in the
unconfirmed pool until the missing cosignature arrives.
Cosignatory: Cosigning the Pending Transaction⚓︎
// [Cosignatory 1] Select the pending multisig transactionletinnerTransactionHash=null;letresolveCosigned;constcosigned=newPromise(resolve=>{resolveCosigned=resolve;});constonUnconfirmed=asyncmessage=>{if(null!==innerTransactionHash)return;constbody=JSON.parse(message.body);constsigner=(body.transaction.otherTrans?.signer??'').toUpperCase();if(multisigPublicKey.toString()!==signer)return;innerTransactionHash=body.meta.innerHash.data;console.log('unconfirmed: innerHash='+`${innerTransactionHash.substring(0,16)}...`);
The pending multisig transaction arrives on the unconfirmed/{address}WS channel as a
TransactionMetaDataPair.
For multisig transactions, the meta field contains an additional innerHash field, holding the hash of the
inner transaction, which is the value that a cosignature must reference.
A cosignatory can have multiple pending multisig transactions awaiting approval.
In this example, the code selects the transaction issued by the multisig account.
This is enough for the tutorial because only one pending transaction is expected from that account.
In real applications, however, this filter is not enough if the multisig account has multiple pending transactions.
Instead, inspect the content of each pending transaction, such as its type, recipient, and amount, before selecting the
one to cosign.
Verify before cosigning
Always verify the contents of a transaction before cosigning it.
Cosignatures are binding and cannot be undone.
The full multisig transaction is available in the notification's transaction field for inspection.
# [Cosignatory 1] Cosign the pending transactioncosignature=facade.transaction_factory.create({'type':'cosignature_v1',# This is the cosignatory providing the second signature'signer_public_key':cosignatory1_key_pair.public_key,'timestamp':timestamp.timestamp,'deadline':deadline.timestamp,# Hash of the inner transfer transaction'other_transaction_hash':inner_transaction_hash,# Address of the multisig account'multisig_account_address':multisig_address})cosignature.fee=Amount(calculate_transaction_fee(cosignature))cosignature_signature=facade.sign_transaction(cosignatory1_key_pair,cosignature)cosignature_payload=(facade.transaction_factory.attach_signature(cosignature,cosignature_signature))cosignature_request=urllib.request.Request(f'{NODE_URL}/transaction/announce',data=cosignature_payload.encode(),headers={'Content-Type':'application/json'},method='POST')withurllib.request.urlopen(cosignature_request)asresp:cosignature_result=json.loads(resp.read().decode())if'SUCCESS'!=cosignature_result['message']:print('Cosignature rejected: 'f'{cosignature_result["message"]}')returnprint('[Cosignatory 1] Announced cosignature')break
// [Cosignatory 1] Cosign the pending transactionconstcosignature=facade.transactionFactory.create({type:'cosignature_v1',// This is the cosignatory providing the second signaturesignerPublicKey:cosignatory1KeyPair.publicKey.toString(),timestamp:timestamp.timestamp,deadline:deadline.timestamp,// Hash of the inner transfer transactionotherTransactionHash:innerTransactionHash,// Address of the multisig accountmultisigAccountAddress:multisigAddress});cosignature.fee=newmodels.Amount(calculateTransactionFee(cosignature));constcosignatureSignature=facade.signTransaction(cosignatory1KeyPair,cosignature);constcosignaturePayload=facade.transactionFactory.static.attachSignature(cosignature,cosignatureSignature);constcosignatureResponse=awaitfetch(`${NODE_URL}/transaction/announce`,{method:'POST',headers:{'Content-Type':'application/json'},body:cosignaturePayload});constcosignatureResult=awaitcosignatureResponse.json();if('SUCCESS'!==cosignatureResult.message){console.log(`Cosignature rejected: ${cosignatureResult.message}`);resolveCosigned(false);return;}console.log('[Cosignatory 1] Announced cosignature');resolveCosigned(true);
The code then builds a CosignatureV1 referencing the inner transaction hash and the multisig account
address, signs it with Cosignatory 1's key, and announces it using the /transaction/announcePOST endpoint.
The announced cosignature does not appear in the unconfirmed pool as a separate transaction, so it does not trigger
a notification of its own.
Instead, the network attaches it to the pending multisig transaction, which triggers a new notification on the
unconfirmed/{address}WS channel.
Since this update only reflects the addition of a cosignature, the code ignores it.
If the multisig transaction requires additional cosignatures, it remains in the unconfirmed pool until all required
cosignatures have been collected.
In this tutorial, the second cosignature satisfies the multisig requirements, so the transaction leaves the unconfirmed
pool and, if valid, is confirmed in the next block.
The confirmation arrives on the transactions/{address}WS channel.
Since both the sender and the recipient of the inner transfer are the multisig account, this notification is delivered
twice, once for each role.
The code prints both notifications, but reports the confirmation only once.
The block that includes the transaction also triggers a final notification on the account/{address}WS
channel with the account's updated state.
Once this final notification arrives, the program moves on to the cleanup step.
# [Cosignatory 1] Unsubscribe before closingforsub_idinchannels.values():awaitstomp_unsubscribe(websocket,sub_id)print('[Cosignatory 1] Unsubscribed from all channels')awaitstomp_disconnect(websocket)
// [Cosignatory 1] Unsubscribe before closingfor(const{id}ofsubscriptions)client.unsubscribe(id);console.log('[Cosignatory 1] Unsubscribed from all channels');client.deactivate();
After confirmation, Cosignatory 1 unsubscribes from the three channels and ends the STOMP session before the connection
closes.
Using node http://libertalia.nemtest.net:7890
Multisig address: TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6
Cosignatory 0 public key: AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3
Cosignatory 1 public key: 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23
[Cosignatory 0] Built multisig transaction 844BBBB420167B0D...
[Cosignatory 1] Connected to http://libertalia.nemtest.net:7778
[Cosignatory 1] Subscribed to /account/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /unconfirmed/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /transactions/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
Account update: balance=9959750000
[Cosignatory 1] Multisig account registered
[Cosignatory 0] Announcing multisig transaction 844BBBB420167B0D...
unconfirmed: innerHash=3e1fba4d39d9f053...
[Cosignatory 1] Announced cosignature
confirmed: innerHash=3e1fba4d39d9f053...
Multisig transaction confirmed
confirmed: innerHash=3e1fba4d39d9f053...
Account update: balance=9959400000
[Cosignatory 1] Unsubscribed from all channels
The output shows:
Accounts (lines 2-4): The multisig account address and the public keys of both cosignatories.
Build (line 5): Cosignatory 0 builds and signs the multisig transaction.
Connection (line 6): The STOMP session is established over the node's WebSocket endpoint at port 7778.
Subscriptions (lines 7-9): The three channels, all scoped to the multisig account's address, are subscribed.
Registration (lines 11): The multisig account's current state arrives on the account channel, confirming
the registration.
Announcement (line 12): Cosignatory 0 announces the multisig transaction.
Cosigning (lines 13-14): The pending multisig transaction arrives on the unconfirmed channel with its inner
transaction hash, and Cosignatory 1 announces the cosignature.
Confirmation (lines 15-17): The completed transaction is confirmed in a block.
The notification arrives twice because the inner transfer's sender and recipient are both the multisig account.
Account update (line 18): The block containing the transaction triggers a final account notification.
The balance is reduced by the 0.35 XEM in fees, since the
transferred 1 XEM returns to the sender.
Unsubscribe (line 19): The code unsubscribes from the three channels.