A bonded aggregate transaction collects signatures on-chain after being announced.
This works well when off-chain coordination is impractical. For example:
No shared infrastructure: Parties cannot coordinate through a common system, so the blockchain serves
as the common interface.
Asynchronous workflows: Cosigners are not available at the same time or cannot coordinate in real-time.
To prevent spam, bonded aggregates require a hash lock (a deposit of 10 XYM).
The network returns this deposit when all cosignatures arrive and the transaction reaches confirmation.
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.
importjsonimportosimporttimeimporturllib.requestfromsymbolchain.CryptoTypesimportHash256,PrivateKeyfromsymbolchain.facade.SymbolFacadeimportSymbolFacadefromsymbolchain.scimportAmountfromsymbolchain.symbol.IdGeneratorimportgenerate_mosaic_alias_idfromsymbolchain.symbol.NetworkimportNetworkTimestampNODE_URL=os.getenv('NODE_URL','https://reference.symboltest.net:3001')print(f'Using node {NODE_URL}')# Helper function to announce transactiondefannounce_transaction(payload,endpoint,label):print(f'Announcing {label} to {endpoint}')request=urllib.request.Request(f'{NODE_URL}{endpoint}',data=payload.encode(),headers={'Content-Type':'application/json'},method='PUT')withurllib.request.urlopen(request)asresponse:print(f' Response: {response.read().decode()}')# Helper function to wait for transaction statusdefwait_for_status(hash_value,expected_status,label):print(f'Waiting for {label} to reach {expected_status} status...')attempts=0max_attempts=60whileattempts<max_attempts:try:url=f'{NODE_URL}/transactionStatus/{hash_value}'withurllib.request.urlopen(url)asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status["group"]}')ifstatus['group']=='failed':raiseException(f'{label} failed: {status["code"]}')ifstatus['group']==expected_status:print(f'{label}{expected_status} '+f'in {attempts} seconds')returnexcepturllib.error.HTTPErrorase:ife.code!=404:raiseprint(' Transaction status: not yet available')attempts+=1time.sleep(1)raiseException(f'{label} not {expected_status} after {max_attempts} attempts')# Account A (initiates the aggregate tx and sends XYM to Account B)ACCOUNT_A_PRIVATE_KEY=os.getenv('ACCOUNT_A_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000000')account_a_key_pair=SymbolFacade.KeyPair(PrivateKey(ACCOUNT_A_PRIVATE_KEY))# Account B (sends custom mosaic to Account A)ACCOUNT_B_PRIVATE_KEY=os.getenv('ACCOUNT_B_PRIVATE_KEY','1111111111111111111111111111111111111111111111111111111111111111')account_b_key_pair=SymbolFacade.KeyPair(PrivateKey(ACCOUNT_B_PRIVATE_KEY))facade=SymbolFacade('testnet')account_a_address=facade.network.public_key_to_address(account_a_key_pair.public_key)account_b_address=facade.network.public_key_to_address(account_b_key_pair.public_key)print(f'Account A: {account_a_address}')print(f'Account B: {account_b_address}')try:# Fetch current network timetime_path='/node/time'print(f'Fetching current network time from {time_path}')withurllib.request.urlopen(f'{NODE_URL}{time_path}')asresponse:response_json=json.loads(response.read().decode())receive_timestamp=(response_json['communicationTimestamps']['receiveTimestamp'])timestamp=NetworkTimestamp(int(receive_timestamp))print(f' Network time: {timestamp.timestamp} ms since nemesis')# Fetch recommended feesfee_path='/network/fees/transaction'print(f'Fetching recommended fees from {fee_path}')withurllib.request.urlopen(f'{NODE_URL}{fee_path}')asresponse:response_json=json.loads(response.read().decode())median_mult=response_json['medianFeeMultiplier']minimum_mult=response_json['minFeeMultiplier']fee_mult=max(median_mult,minimum_mult)print(f' Fee multiplier: {fee_mult}')# Embedded tx 1: Account A transfers 10 XYM to Account Bembedded_transaction_1=facade.transaction_factory.create_embedded({'type':'transfer_transaction_v1','signer_public_key':account_a_key_pair.public_key,'recipient_address':account_b_address,'mosaics':[{'mosaic_id':generate_mosaic_alias_id('symbol.xym'),'amount':10_000_000# 10 XYM (divisibility = 6)}]})# Embedded tx 2: Account B transfers 1 custom mosaic to Account Acustom_mosaic_id=0x6D1314BE751B62C2embedded_transaction_2=facade.transaction_factory.create_embedded({'type':'transfer_transaction_v1','signer_public_key':account_b_key_pair.public_key,'recipient_address':account_a_address,'mosaics':[{'mosaic_id':custom_mosaic_id,'amount':1# 1 custom mosaic (divisibility = 0)}]})# Build the bonded aggregate transactionembedded_transactions=[embedded_transaction_1,embedded_transaction_2]bonded_transaction=facade.transaction_factory.create({'type':'aggregate_bonded_transaction_v3','signer_public_key':account_a_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})# Reserve space for one cosignature (104 bytes)# and calculate fee for the final transaction sizebonded_transaction.fee=Amount(fee_mult*(bonded_transaction.size+104))print('Built aggregate without signatures:')print(json.dumps(bonded_transaction.to_json(),indent=2))# --- ACCOUNT A (Initiator) ---# Sign the bonded aggregate transactionprint('[Account A] Signing the bonded aggregate...')bonded_signature=facade.sign_transaction(account_a_key_pair,bonded_transaction)bonded_json_payload=facade.transaction_factory.attach_signature(bonded_transaction,bonded_signature)bonded_hash=facade.hash_transaction(bonded_transaction)print(f'Bonded aggregate transaction hash: {bonded_hash}')# Create hash lock transactionprint('Creating hash lock transaction...')hash_lock=facade.transaction_factory.create({'type':'hash_lock_transaction_v1','signer_public_key':account_a_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'mosaic':{'mosaic_id':generate_mosaic_alias_id('symbol.xym'),'amount':10_000_000# 10 XYM deposit},'duration':100,# Lock duration in blocks'hash':bonded_hash})hash_lock.fee=Amount(fee_mult*hash_lock.size)# Sign hash lockprint('[Account A] Signing the hash lock...')hash_lock_signature=facade.sign_transaction(account_a_key_pair,hash_lock)hash_lock_payload=facade.transaction_factory.attach_signature(hash_lock,hash_lock_signature)hash_lock_hash=facade.hash_transaction(hash_lock)print(f'Hash lock transaction hash: {hash_lock_hash}')# Announce hash lock and wait for confirmationannounce_transaction(hash_lock_payload,'/transactions','Hash lock')wait_for_status(hash_lock_hash,'confirmed','Hash lock')# Announce bonded aggregate and wait for partial statusannounce_transaction(bonded_json_payload,'/transactions/partial','Bonded aggregate transaction')wait_for_status(bonded_hash,'partial','Bonded aggregate transaction')# --- ACCOUNT B (Cosigner) ---# Retrieves partial transactions waiting for signaturepartial_path=f'/transactions/partial?address={account_b_address}'print('[Account B] Checking for partial transactions from ''/transactions/partial')withurllib.request.urlopen(f'{NODE_URL}{partial_path}')asresponse:partial_txs=json.loads(response.read().decode())ifnotpartial_txs['data']:raiseException('No partial transactions found')print(f'Found {len(partial_txs["data"])} partial transaction(s)')# Find the transaction matching the expected hashfound=any(tx['meta']['hash']==str(bonded_hash)fortxinpartial_txs['data'])ifnotfound:raiseException(f'Expected transaction {bonded_hash} not found in 'f'partial transactions')print(f'Found matching transaction: {bonded_hash}')# Fetch full transaction details using the hashdetail_path=f'/transactions/partial/{bonded_hash}'withurllib.request.urlopen(f'{NODE_URL}{detail_path}')asresponse:partial_tx_json=json.loads(response.read().decode())# Verify transaction content before cosigningtx_data=partial_tx_json['transaction']print(f'[Account B] Verifying transaction: 'f'{len(tx_data["transactions"])} embedded transactions')# Submit Account B's cosignature using the transaction hashcosignature_path='/transactions/cosignature'print('[Account B] Cosigning the bonded aggregate...')cosignature=facade.cosign_transaction_hash(account_b_key_pair,bonded_hash,True)cosignature_payload=json.dumps({'version':str(cosignature.version),'signerPublicKey':str(cosignature.signer_public_key),'signature':str(cosignature.signature),'parentHash':str(cosignature.parent_hash)})# Announce cosignatureannounce_transaction(cosignature_payload,cosignature_path,'cosignature')# Wait for final confirmationwait_for_status(bonded_hash,'confirmed','Bonded aggregate transaction')exceptExceptionase:print(e)
import{Hash256,PrivateKey}from'symbol-sdk';import{generateMosaicAliasId,models,NetworkTimestamp,SymbolFacade}from'symbol-sdk/symbol';constNODE_URL=process.env.NODE_URL||'https://reference.symboltest.net:3001';console.log('Using node',NODE_URL);// Helper function to announce transactionasyncfunctionannounceTransaction(payload,endpoint,label){console.log(`Announcing ${label} to ${endpoint}`);constresponse=awaitfetch(`${NODE_URL}${endpoint}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:payload});console.log(' Response:',awaitresponse.text());}// Helper function to wait for transaction statusasyncfunctionwaitForStatus(hash,expectedStatus,label){console.log(`Waiting for ${label} to reach ${expectedStatus} status...`);letattempts=0;constmaxAttempts=60;while(attempts<maxAttempts){try{consturl=`${NODE_URL}/transactionStatus/${hash}`;constresponse=awaitfetch(url);if(!response.ok){consterror=newError(`HTTP ${response.status}: ${response.statusText}`);error.status=response.status;throwerror;}conststatus=awaitresponse.json();console.log(' Transaction status:',status.group);if(status.group==='failed'){thrownewError(`${label} failed: ${status.code}`);}if(status.group===expectedStatus){console.log(`${label}${expectedStatus} in ${attempts} seconds`);return;}}catch(error){if(error.status===404){console.log(' Transaction status: not yet available');}else{throwerror;}}attempts++;awaitnewPromise(resolve=>setTimeout(resolve,1000));}thrownewError(`${label} not ${expectedStatus} after ${maxAttempts} attempts`);}// Account A (initiates the aggregate tx and sends XYM to Account B)constACCOUNT_A_PRIVATE_KEY=process.env.ACCOUNT_A_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000000');constaccountAKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ACCOUNT_A_PRIVATE_KEY));// Account B (sends custom mosaic to Account A)constACCOUNT_B_PRIVATE_KEY=process.env.ACCOUNT_B_PRIVATE_KEY||('1111111111111111111111111111111111111111111111111111111111111111');constaccountBKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ACCOUNT_B_PRIVATE_KEY));constfacade=newSymbolFacade('testnet');constaccountAAddress=facade.network.publicKeyToAddress(accountAKeyPair.publicKey);constaccountBAddress=facade.network.publicKeyToAddress(accountBKeyPair.publicKey);console.log('Account A:',accountAAddress.toString());console.log('Account B:',accountBAddress.toString());try{// Fetch current network timeconsttimePath='/node/time';console.log('Fetching current network time from',timePath);consttimeResponse=awaitfetch(`${NODE_URL}${timePath}`);consttimeJSON=awaittimeResponse.json();consttimestamp=newNetworkTimestamp(timeJSON.communicationTimestamps.receiveTimestamp);console.log(' Network time:',timestamp.timestamp,'ms since nemesis');// Fetch recommended feesconstfeePath='/network/fees/transaction';console.log('Fetching recommended fees from',feePath);constfeeResponse=awaitfetch(`${NODE_URL}${feePath}`);constfeeJSON=awaitfeeResponse.json();constmedianMult=feeJSON.medianFeeMultiplier;constminimumMult=feeJSON.minFeeMultiplier;constfeeMult=Math.max(medianMult,minimumMult);console.log(' Fee multiplier:',feeMult);// Embedded tx 1: Account A transfers 10 XYM to Account BconstembeddedTransaction1=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountAKeyPair.publicKey.toString(),recipientAddress:accountBAddress.toString(),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:10_000_000n// 10 XYM (divisibility = 6)}]});// Embedded tx 2: Account B transfers 1 custom mosaic to Account AconstcustomMosaicId=0x6D1314BE751B62C2n;constembeddedTransaction2=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountBKeyPair.publicKey.toString(),recipientAddress:accountAAddress.toString(),mosaics:[{mosaicId:customMosaicId,amount:1n// 1 custom mosaic (divisibility = 0)}]});// Build the bonded aggregate transactionconstembeddedTransactions=[embeddedTransaction1,embeddedTransaction2];constbondedTransaction=facade.transactionFactory.create({type:'aggregate_bonded_transaction_v3',signerPublicKey:accountAKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(embeddedTransactions),transactions:embeddedTransactions});// Reserve space for one cosignature (104 bytes)// and calculate fee for the final transaction sizebondedTransaction.fee=newmodels.Amount(feeMult*(bondedTransaction.size+104));console.log('Built aggregate without signatures:');console.log(JSON.stringify(bondedTransaction.toJson(),null,2));// --- ACCOUNT A (Initiator) ---// Sign the bonded aggregate transactionconsole.log('[Account A] Signing the bonded aggregate...');constbondedSignature=facade.signTransaction(accountAKeyPair,bondedTransaction);constbondedJsonPayload=facade.transactionFactory.static.attachSignature(bondedTransaction,bondedSignature);constbondedHash=facade.hashTransaction(bondedTransaction).toString();console.log('Bonded aggregate transaction hash:',bondedHash);// Create hash lock transactionconsole.log('Creating hash lock transaction...');consthashLock=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// 10 XYM deposit},duration:100n,// Lock duration in blockshash:bondedHash});hashLock.fee=newmodels.Amount(feeMult*hashLock.size);// Sign hash lockconsole.log('[Account A] Signing the hash lock...');consthashLockSignature=facade.signTransaction(accountAKeyPair,hashLock);consthashLockPayload=facade.transactionFactory.static.attachSignature(hashLock,hashLockSignature);consthashLockHash=facade.hashTransaction(hashLock).toString();console.log('Hash lock transaction hash:',hashLockHash);// Announce hash lock and wait for confirmationawaitannounceTransaction(hashLockPayload,'/transactions','Hash lock');awaitwaitForStatus(hashLockHash,'confirmed','Hash lock');// Announce bonded aggregate and wait for partial statusawaitannounceTransaction(bondedJsonPayload,'/transactions/partial','Bonded aggregate transaction');awaitwaitForStatus(bondedHash,'partial','Bonded aggregate transaction');// --- ACCOUNT B (Cosigner) ---// Retrieves partial transactions waiting for signatureconstpartialPath=`/transactions/partial?address=${accountBAddress}`;console.log('[Account B] Checking for partial transactions from '+'/transactions/partial');constpartialResponse=awaitfetch(`${NODE_URL}${partialPath}`);constpartialTxs=awaitpartialResponse.json();if(!partialTxs.data||partialTxs.data.length===0){thrownewError('No partial transactions found');}console.log(`Found ${partialTxs.data.length} partial transaction(s)`);// Find the transaction matching the expected hashconstfound=partialTxs.data.some(tx=>tx.meta.hash===bondedHash);if(!found){thrownewError(`Expected transaction ${bondedHash} not found in `+`partial transactions`);}console.log(`Found matching transaction: ${bondedHash}`);// Fetch full transaction details using the hashconstdetailPath=`/transactions/partial/${bondedHash}`;constdetailResponse=awaitfetch(`${NODE_URL}${detailPath}`);constpartialTxJson=awaitdetailResponse.json();// Verify transaction content before cosigningconsttxData=partialTxJson.transaction;console.log(`[Account B] Verifying transaction: `+`${txData.transactions.length} embedded transactions`);// Submit Account B's cosignature using the transaction hashconstcosignaturePath='/transactions/cosignature';console.log('[Account B] Cosigning the bonded aggregate...');constcosignature=SymbolFacade.cosignTransactionHash(accountBKeyPair,newHash256(bondedHash),true);constcosignaturePayload=JSON.stringify({version:cosignature.version.toString(),signerPublicKey:cosignature.signerPublicKey.toString(),signature:cosignature.signature.toString(),parentHash:cosignature.parentHash.toString()});// Announce cosignatureawaitannounceTransaction(cosignaturePayload,cosignaturePath,'cosignature');// Wait for final confirmationawaitwaitForStatus(newHash256(bondedHash),'confirmed','Bonded aggregate transaction');}catch(e){console.error(e.message,'| Cause:',e.cause?.code??'unknown');}
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 poll for pending
transactions and add their signatures after verifying the transaction.
In practice, each role runs as a separate program on a separate machine.
This tutorial combines both roles in a single script for simplicity.
The whole code is wrapped in a single try block to provide simple error handling,
but applications will probably want to use more fine-grained control.
# Account A (initiates the aggregate tx and sends XYM to Account B)ACCOUNT_A_PRIVATE_KEY=os.getenv('ACCOUNT_A_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000000')account_a_key_pair=SymbolFacade.KeyPair(PrivateKey(ACCOUNT_A_PRIVATE_KEY))# Account B (sends custom mosaic to Account A)ACCOUNT_B_PRIVATE_KEY=os.getenv('ACCOUNT_B_PRIVATE_KEY','1111111111111111111111111111111111111111111111111111111111111111')account_b_key_pair=SymbolFacade.KeyPair(PrivateKey(ACCOUNT_B_PRIVATE_KEY))facade=SymbolFacade('testnet')account_a_address=facade.network.public_key_to_address(account_a_key_pair.public_key)account_b_address=facade.network.public_key_to_address(account_b_key_pair.public_key)print(f'Account A: {account_a_address}')print(f'Account B: {account_b_address}')
// Account A (initiates the aggregate tx and sends XYM to Account B)constACCOUNT_A_PRIVATE_KEY=process.env.ACCOUNT_A_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000000');constaccountAKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ACCOUNT_A_PRIVATE_KEY));// Account B (sends custom mosaic to Account A)constACCOUNT_B_PRIVATE_KEY=process.env.ACCOUNT_B_PRIVATE_KEY||('1111111111111111111111111111111111111111111111111111111111111111');constaccountBKeyPair=newSymbolFacade.KeyPair(newPrivateKey(ACCOUNT_B_PRIVATE_KEY));constfacade=newSymbolFacade('testnet');constaccountAAddress=facade.network.publicKeyToAddress(accountAKeyPair.publicKey);constaccountBAddress=facade.network.publicKeyToAddress(accountBKeyPair.publicKey);console.log('Account A:',accountAAddress.toString());console.log('Account B:',accountBAddress.toString());
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 as defaults.
If using your own keys, ensure Account A has XYM and Account B holds a custom mosaic for the swap.
The addresses for both accounts are derived from their public keys using the facade's network configuration.
# Fetch current network timetime_path='/node/time'print(f'Fetching current network time from {time_path}')withurllib.request.urlopen(f'{NODE_URL}{time_path}')asresponse:response_json=json.loads(response.read().decode())receive_timestamp=(response_json['communicationTimestamps']['receiveTimestamp'])timestamp=NetworkTimestamp(int(receive_timestamp))print(f' Network time: {timestamp.timestamp} ms since nemesis')# Fetch recommended feesfee_path='/network/fees/transaction'print(f'Fetching recommended fees from {fee_path}')withurllib.request.urlopen(f'{NODE_URL}{fee_path}')asresponse:response_json=json.loads(response.read().decode())median_mult=response_json['medianFeeMultiplier']minimum_mult=response_json['minFeeMultiplier']fee_mult=max(median_mult,minimum_mult)print(f' Fee multiplier: {fee_mult}')
// Embedded tx 1: Account A transfers 10 XYM to Account BconstembeddedTransaction1=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountAKeyPair.publicKey.toString(),recipientAddress:accountBAddress.toString(),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:10_000_000n// 10 XYM (divisibility = 6)}]});// Embedded tx 2: Account B transfers 1 custom mosaic to Account AconstcustomMosaicId=0x6D1314BE751B62C2n;constembeddedTransaction2=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:accountBKeyPair.publicKey.toString(),recipientAddress:accountAAddress.toString(),mosaics:[{mosaicId:customMosaicId,amount:1n// 1 custom mosaic (divisibility = 0)}]});
The embedded transactions define the operations to execute atomically.
Each embedded transaction specifies:
Type: All transaction types can be embedded within aggregates (except other aggregates).
For embedded transfers, use transfer_transaction_v1, the same as for basic transfer transactions.
Signer public key: The account that would sign this transaction if it were announced independently.
Transaction-specific fields: All fields specific to the transaction type must be provided.
For transfers, this includes the recipient address and the mosaics to send.
Note that embedded transactions do not include fee or deadline fields.
These are inherited from the enclosing aggregate transaction.
The first transfer sends 10 XYM from Account A to Account B.
The second transfer sends 1 custom mosaic from Account B to Account A.
About the custom mosaic
The custom mosaic with ID 0x6D1314BE751B62C2 was created for this tutorial.
The default Account B has been seeded with this mosaic so the swap can execute successfully.
If using your own accounts, ensure Account B holds a custom mosaic and update the mosaic ID in the code.
# Build the bonded aggregate transactionembedded_transactions=[embedded_transaction_1,embedded_transaction_2]bonded_transaction=facade.transaction_factory.create({'type':'aggregate_bonded_transaction_v3','signer_public_key':account_a_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})# Reserve space for one cosignature (104 bytes)# and calculate fee for the final transaction sizebonded_transaction.fee=Amount(fee_mult*(bonded_transaction.size+104))print('Built aggregate without signatures:')print(json.dumps(bonded_transaction.to_json(),indent=2))
// Build the bonded aggregate transactionconstembeddedTransactions=[embeddedTransaction1,embeddedTransaction2];constbondedTransaction=facade.transactionFactory.create({type:'aggregate_bonded_transaction_v3',signerPublicKey:accountAKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(embeddedTransactions),transactions:embeddedTransactions});// Reserve space for one cosignature (104 bytes)// and calculate fee for the final transaction sizebondedTransaction.fee=newmodels.Amount(feeMult*(bondedTransaction.size+104));console.log('Built aggregate without signatures:');console.log(JSON.stringify(bondedTransaction.toJson(),null,2));
Once the embedded transactions are prepared, create the bonded aggregate transaction that wraps them:
Signer public key: The account initiating the aggregate.
This account announces the transaction and pays the transaction fee.
Deadline: The timestamp, in network time, after which the transaction
expires and can no longer be confirmed.
Transactions hash: A hash computed from all embedded transactions.
This ensures the embedded transactions cannot be modified after signing.
Use to compute this value.
Transactions: The array of embedded transactions to execute.
The fee is calculated based on the aggregate's total size, which includes all embedded transactions plus space reserved
for one cosignature (104 bytes).
// Create hash lock transactionconsole.log('Creating hash lock transaction...');consthashLock=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// 10 XYM deposit},duration:100n,// Lock duration in blockshash:bondedHash});hashLock.fee=newmodels.Amount(feeMult*hashLock.size);// Sign hash lockconsole.log('[Account A] Signing the hash lock...');consthashLockSignature=facade.signTransaction(accountAKeyPair,hashLock);consthashLockPayload=facade.transactionFactory.static.attachSignature(hashLock,hashLockSignature);consthashLockHash=facade.hashTransaction(hashLock).toString();console.log('Hash lock transaction hash:',hashLockHash);// Announce hash lock and wait for confirmationawaitannounceTransaction(hashLockPayload,'/transactions','Hash lock');awaitwaitForStatus(hashLockHash,'confirmed','Hash lock');
Before announcing a bonded aggregate, a hash lock transaction must be created and confirmed.
The hash lock serves as a deposit to prevent spam and ensure network resources are not exhausted by unfinished
partial transactions.
The hash lock transaction specifies:
Type: Use hash_lock_transaction_v1.
Mosaic: The deposit amount (10 XYM).
This deposit is locked temporarily while waiting for cosignatures.
Duration: The number of blocks the deposit remains locked (100 blocks in this example).
If all cosignatures are collected and the bonded aggregate confirms before the duration expires,
the deposit is returned. Otherwise, it is forfeited.
Hash: The hash of the bonded aggregate transaction being locked.
The hash lock is signed using and announced using the announce_transaction helper
function.
It must be confirmed before the bonded aggregate can be announced.
Then, the wait_for_status helper function polls the transaction status until confirmation.
Once the hash lock is confirmed, the bonded aggregate is announced to /transactions/partialPUT using the
announce_transaction helper.
The node validates the transaction, checks that a valid hash lock exists, and places it in a partial state.
The wait_for_status helper monitors the transaction until it reaches this state, at which point it can collect
cosignatures.
# --- ACCOUNT B (Cosigner) ---# Retrieves partial transactions waiting for signaturepartial_path=f'/transactions/partial?address={account_b_address}'print('[Account B] Checking for partial transactions from ''/transactions/partial')withurllib.request.urlopen(f'{NODE_URL}{partial_path}')asresponse:partial_txs=json.loads(response.read().decode())ifnotpartial_txs['data']:raiseException('No partial transactions found')print(f'Found {len(partial_txs["data"])} partial transaction(s)')# Find the transaction matching the expected hashfound=any(tx['meta']['hash']==str(bonded_hash)fortxinpartial_txs['data'])ifnotfound:raiseException(f'Expected transaction {bonded_hash} not found in 'f'partial transactions')print(f'Found matching transaction: {bonded_hash}')
// --- ACCOUNT B (Cosigner) ---// Retrieves partial transactions waiting for signatureconstpartialPath=`/transactions/partial?address=${accountBAddress}`;console.log('[Account B] Checking for partial transactions from '+'/transactions/partial');constpartialResponse=awaitfetch(`${NODE_URL}${partialPath}`);constpartialTxs=awaitpartialResponse.json();if(!partialTxs.data||partialTxs.data.length===0){thrownewError('No partial transactions found');}console.log(`Found ${partialTxs.data.length} partial transaction(s)`);// Find the transaction matching the expected hashconstfound=partialTxs.data.some(tx=>tx.meta.hash===bondedHash);if(!found){thrownewError(`Expected transaction ${bondedHash} not found in `+`partial transactions`);}console.log(`Found matching transaction: ${bondedHash}`);
Unlike complete aggregates where the transaction payload is shared off-chain, bonded aggregates enable on-chain
coordination.
First, Account B polls /transactions/partialGET with the address parameter to find transactions waiting for its
signature.
This returns a list of partial transactions involving Account B.
This example looks for a specific transaction hash because both accounts run in the same script.
In practice, Account B would discover pending transactions by polling and decide which ones to cosign based on
their content.
# Fetch full transaction details using the hashdetail_path=f'/transactions/partial/{bonded_hash}'withurllib.request.urlopen(f'{NODE_URL}{detail_path}')asresponse:partial_tx_json=json.loads(response.read().decode())# Verify transaction content before cosigningtx_data=partial_tx_json['transaction']print(f'[Account B] Verifying transaction: 'f'{len(tx_data["transactions"])} embedded transactions')
// Fetch full transaction details using the hashconstdetailPath=`/transactions/partial/${bondedHash}`;constdetailResponse=awaitfetch(`${NODE_URL}${detailPath}`);constpartialTxJson=awaitdetailResponse.json();// Verify transaction content before cosigningconsttxData=partialTxJson.transaction;console.log(`[Account B] Verifying transaction: `+`${txData.transactions.length} embedded transactions`);
Before cosigning, Account B should verify that the embedded transactions match the expected operations.
This example simply logs the number of embedded transactions, but it could also check amounts,
recipients, and mosaics to ensure the swap terms are correct.
Verify before cosigning
Always inspect transaction content before cosigning.
Cosignatures are binding and cannot be undone.
// Submit Account B's cosignature using the transaction hashconstcosignaturePath='/transactions/cosignature';console.log('[Account B] Cosigning the bonded aggregate...');constcosignature=SymbolFacade.cosignTransactionHash(accountBKeyPair,newHash256(bondedHash),true);constcosignaturePayload=JSON.stringify({version:cosignature.version.toString(),signerPublicKey:cosignature.signerPublicKey.toString(),signature:cosignature.signature.toString(),parentHash:cosignature.parentHash.toString()});// Announce cosignatureawaitannounceTransaction(cosignaturePayload,cosignaturePath,'cosignature');
Account B cosigns the transaction using with the transaction hash and the
detached parameter set to true.
A detached cosignature is a standalone object that can be submitted independently to the network.
This is required for bonded aggregates because the cosigner submits directly to the node.
The resulting detached cosignature payload includes:
Version: The cosignature format version.
Signer public key: Account B's public key, identifying who cosigned.
Signature: The cryptographic signature computed from the transaction hash and Account B's private key.
Parent hash: The hash of the bonded transaction being cosigned.
The cosignature payload is submitted using the announce_transaction helper function to
/transactions/cosignaturePUT.
The network validates the cosignature and attaches it to the partial transaction.
Once enough cosignatures are collected to satisfy all embedded transactions,
the network automatically processes the bonded aggregate and includes it in a block.
// Wait for final confirmationawaitwaitForStatus(newHash256(bondedHash),'confirmed','Bonded aggregate transaction');
The wait_for_status helper function polls /transactionStatus/{hash}GET until the transaction is confirmed or fails.
If all required cosignatures are collected before the deadline, the transaction confirms, both transfers execute,
and the hash lock deposit is returned to Account A.
If the deadline expires or any cosignature is invalid, the transaction fails and the deposit is forfeited.
Line 18 ("transactions"): Contains the two embedded transfers that will execute atomically.
Line 48 ("cosignatures": []): Initially empty. Cosignatures are submitted on-chain after announcement.
Line 51 (Bonded aggregate transaction hash:): The hash of the bonded aggregate, required for creating the hash
lock and announcing the transaction.
Line 54 (Announcing Hash lock to /transactions): A hash lock must be announced and confirmed before the bonded
aggregate.
Line 63 (Announcing Bonded aggregate transaction to /transactions/partial): Bonded aggregates use a different
endpoint than regular transactions.
Line 67 (Bonded aggregate transaction partial in 1 seconds): The bonded aggregate is now waiting for
cosignatures to be submitted on-chain.
Line 71 ([Account B] Verifying transaction: 2 embedded transactions): Account B inspects the transaction content
before cosigning to ensure they agree with all operations.
Line 73 (Announcing cosignature to /transactions/cosignature): The cosignature is submitted to the network.
The aggregate transaction is treated as a single atomic unit by the network.
The swap executes completely: Account A receives the custom mosaic and Account B receives the XYM,
or the entire transaction fails and no assets are transferred.