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.environ.get('NODE_URL','https://001-sai-dual.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:raise# Transaction status not yet availableattempts+=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://001-sai-dual.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){// 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');}
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 to demonstrate the complete workflow, but in practice
each party would sign on their own machine without sharing private keys.
The snippet reads the private keys from the ACCOUNT_A_PRIVATE_KEY and ACCOUNT_B_PRIVATE_KEY
environment variables, which default to test keys if not set.
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:
Type: Use aggregate_bonded_transaction_v3.
Signer public key: The account initiating the aggregate.
This account announces the transaction and pays the transaction fee.
Sharing transaction fees
While the signer pays the entire fee upfront, other participants can contribute to the cost by including
XYM transfers back to the signer within the aggregate.
For example, Account B could add XYM to its existing transfer to Account A, or include a separate embedded
transfer transaction for the fee contribution.
This technique allows parties to split costs or even enables one account to send transactions
without holding XYM, since another account covers the 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.
// Announce bonded aggregate and wait for partial statusawaitannounceTransaction(bondedJsonPayload,'/transactions/partial','Bonded aggregate transaction');awaitwaitForStatus(bondedHash,'partial','Bonded aggregate transaction');
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.
Using node https://001-sai-dual.symboltest.net:3001
Account A: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Account B: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI
Fetching current network time from /node/time
Network time: 96983954275 ms since nemesis
Fetching recommended fees from /network/fees/transaction
Fee multiplier: 100
Built aggregate without signatures:
{
"signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
"version": 3,
"network": 152,
"type": 16961,
"fee": "46400",
"deadline": "96991154275",
"transactions_hash": "C2F8137E86FA6CD2B5A6E0CBBEA5485DCC427C5F1C9AC98F7A35E84CF0A28877",
"transactions": [
{
"signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
"version": 1,
"network": 152,
"type": 16724,
"recipient_address": "98AD8BAAB80B1DC684542EC175259711AB2C41D2FE4DA9AD",
"mosaics": [
{
"mosaic_id": "16666583871264174062",
"amount": "10000000"
}
],
"message": ""
},
{
"signer_public_key": "D04AB232742BB4AB3A1368BD4615E4E6D0224AB71A016BAF8520A332C9778737",
"version": 1,
"network": 152,
"type": 16724,
"recipient_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
"mosaics": [
{
"mosaic_id": "7859648582932718274",
"amount": "1"
}
],
"message": ""
}
],
"cosignatures": []
}
[Account A] Signing the bonded aggregate...
Bonded aggregate transaction hash: 59CD54BA6EFC220C9C0C8DA6C7615F2A7F23A302836A69ACE29D48569D8CBAF8
Creating hash lock transaction...
[Account A] Signing the hash lock...
Hash lock transaction hash: 8E7AB27A920195BB783F822C39E14A42E2BA965250114E619D011CFE437ACC68
Announcing Hash lock to /transactions
Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for Hash lock to reach confirmed status...
Transaction status: unconfirmed
Transaction status: unconfirmed
Transaction status: unconfirmed
Transaction status: confirmed
Hash lock confirmed in 24 seconds
Announcing Bonded aggregate transaction to /transactions/partial
Response: {"message":"packet 256 was pushed to the network via /transactions/partial"}
Waiting for Bonded aggregate transaction to reach partial status...
Transaction status: partial
Bonded aggregate transaction partial in 1 seconds
[Account B] Checking for partial transactions from /transactions/partial
Found 1 partial transaction(s)
Found matching transaction: 59CD54BA6EFC220C9C0C8DA6C7615F2A7F23A302836A69ACE29D48569D8CBAF8
[Account B] Verifying transaction: 2 embedded transactions
[Account B] Cosigning the bonded aggregate...
Announcing cosignature to /transactions/cosignature
Response: {"message":"packet 257 was pushed to the network via /transactions/cosignature"}
Waiting for Bonded aggregate transaction to reach confirmed status...
Transaction status: unconfirmed
Transaction status: unconfirmed
Transaction status: unconfirmed
Transaction status: confirmed
Bonded aggregate transaction confirmed in 16 seconds
Key points in the output:
Line 14 ("type": 16961): Indicates this is an aggregate_bonded_transaction_v3.
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.