A complete aggregate transaction can bundle multiple transactions from a single account into one atomic operation,
with one fee and one confirmation.
This is useful for distributing rewards, splitting payments, or funding several accounts at once, for example.
This tutorial shows how to batch two transfer transactions that send XYM to different recipients.
Because all embedded transactions share the same signer, no cosignatures are needed.
The aggregate can be signed and announced by a single account.
For examples requiring the collection of signatures from multiple accounts, see the
Complete Aggregate and Bonded Aggregate tutorials.
You also need an account with enough XYM to cover the transfers and the transaction fee.
Although a pre-funded test account is provided for convenience, it is not maintained and may run out of funds at any time.
To use your own account, complete the following steps:
constSIGNER_PRIVATE_KEY=process.env.SIGNER_PRIVATE_KEY||'0000000000000000000000000000000000000000000000000000000000000000';constsignerKeyPair=newSymbolFacade.KeyPair(newPrivateKey(SIGNER_PRIVATE_KEY));constfacade=newSymbolFacade('testnet');constsignerAddress=facade.network.publicKeyToAddress(signerKeyPair.publicKey);console.log('Signer public key:',signerKeyPair.publicKey.toString());console.log('Signer address:',signerAddress.toString());constRECIPIENT_1=process.env.RECIPIENT_1||'TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI';constRECIPIENT_2=process.env.RECIPIENT_2||'TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI';constrecipient1Hex=Buffer.from(newAddress(RECIPIENT_1).bytes).toString('hex').toUpperCase();constrecipient2Hex=Buffer.from(newAddress(RECIPIENT_2).bytes).toString('hex').toUpperCase();console.log(`Recipient 1: ${RECIPIENT_1} (${recipient1Hex})`);console.log(`Recipient 2: ${RECIPIENT_2} (${recipient2Hex})`);
The signer account is loaded from the SIGNER_PRIVATE_KEY environment variable.
If not provided, a test key is used as default.
The two recipient addresses are loaded from the RECIPIENT_1 and RECIPIENT_2 environment variables.
If not provided, test addresses are used as defaults.
# 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}')
Each transfer is created as an embedded transaction that will be wrapped inside the aggregate.
All embedded transactions use the same signer_public_key because they all originate from the same account.
The signer_public_key is still required on each embedded transaction, even when all share the same signer.
Embedded transactions do not include fee or deadline fields.
These are inherited from the enclosing aggregate transaction.
Batching other transaction types
Although this example batches transfer transactions, any transaction type can be embedded within an aggregate
(except other aggregates).
For example, you could batch mosaic creation with a namespace alias registration in a single atomic operation.
Signer public key: The account that signs the aggregate 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 using
.
This ensures the embedded transactions cannot be modified after signing.
Transactions: The array of embedded transactions to execute.
The fee is calculated based on the aggregate's total size.
Since no cosignatures are needed, there is no need to reserve extra space for cosignature bytes.
# Sign transaction and generate final payloadsignature=facade.sign_transaction(signer_key_pair,transaction)json_payload=(facade.transaction_factory.attach_signature(transaction,signature))# Announce the transactionannounce_path='/transactions'print(f'Announcing transaction to {announce_path}')announce_request=urllib.request.Request(f'{NODE_URL}{announce_path}',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='PUT')withurllib.request.urlopen(announce_request)asresponse:print(f' Response: {response.read().decode()}')
// Sign transaction and generate final payloadconstsignature=facade.signTransaction(signerKeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);// Announce the transactionconstannouncePath='/transactions';console.log('Announcing transaction to',announcePath);constannounceResponse=awaitfetch(`${NODE_URL}${announcePath}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:jsonPayload});console.log(' Response:',awaitannounceResponse.text());
The aggregate is signed with and serialized into a payload using
.
The signed payload is then announced to a node using the /transactionsPUT endpoint, following the same process as
regular transactions described in the Transfer Transaction tutorial.
# Wait for confirmationtransaction_hash=facade.hash_transaction(transaction)status_path=f'/transactionStatus/{transaction_hash}'print(f'Waiting for confirmation from {status_path}')forattemptinrange(60):time.sleep(1)try:withurllib.request.urlopen(f'{NODE_URL}{status_path}')asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status["group"]}')ifstatus['group']=='confirmed':print(f'Transaction confirmed in {attempt} seconds')breakifstatus['group']=='failed':print(f'Transaction failed: {status["code"]}')breakexcepturllib.error.HTTPErrorase:print(f' Transaction status: unknown | Cause: ({e.msg})')else:print('Confirmation took too long.')
// Wait for confirmationconsttransactionHash=facade.hashTransaction(transaction).toString();conststatusPath=`/transactionStatus/${transactionHash}`;console.log('Waiting for confirmation from',statusPath);for(letattempt=0;attempt<60;attempt++){awaitnewPromise(resolve=>setTimeout(resolve,1000));try{conststatusResponse=awaitfetch(`${NODE_URL}${statusPath}`);conststatus=awaitstatusResponse.json();console.log(' Transaction status:',status.group);if(status.group==='confirmed'){console.log('Transaction confirmed in',attempt,'seconds');break;}if(status.group==='failed'){console.log('Transaction failed:',status.code);break;}}catch(e){console.log(' Transaction status: unknown | Cause:',e.message);}}
After announcement, the transaction status is monitored using /transactionStatus/{hash}GET.
The polling loop checks the status every second until the transaction is confirmed or fails.
Lines 26 and 40 ("recipient_address"): The two embedded transfers target different accounts.
These are the hex-encoded forms of the Base32 addresses printed on lines 4-5.
Lines 29-30 and 43-44 ("mosaic_id", "amount"): Each transfer sends XYM (mosaic alias ID
16666583871264174062).
The amounts 5000000 and 3000000 correspond to 5 and 3 XYM because this mosaic has divisibility 6.
Line 50 ("cosignatures": []): Empty because all embedded transactions share the same signer.
No additional signatures are required.
The aggregate transaction executes atomically: both recipients receive their XYM transfers, or neither does.
The transaction hash printed in the output (line 54) can be used to search for the transaction in the
Symbol Testnet Explorer.