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.CryptoTypesimportPrivateKeyfromsymbolchain.facade.SymbolFacadeimportSymbolFacadefromsymbolchain.symbol.IdGeneratorimportgenerate_mosaic_alias_idNODE_URL=os.getenv('NODE_URL','https://reference.symboltest.net:3001')print(f'Using node {NODE_URL}')# Helper function to announce a transactiondefannounce_transaction(payload,label):print(f'Announcing {label} to /transactions')request=urllib.request.Request(f'{NODE_URL}/transactions',data=payload.encode(),headers={'Content-Type':'application/json'},method='PUT')withurllib.request.urlopen(request)asannounce_response:print(f' Response: {announce_response.read().decode()}')# Helper function to wait for transaction confirmationdefwait_for_confirmation(tx_hash,label):print(f'Waiting for {label} confirmation...')forattemptinrange(60):time.sleep(1)try:url=f'{NODE_URL}/transactionStatus/{tx_hash}'withurllib.request.urlopen(url)asconfirm_response:status=json.loads(confirm_response.read().decode())print(f' Transaction status: {status["group"]}')ifstatus['group']=='confirmed':print(f'{label} confirmed in {attempt} seconds')returnifstatus['group']=='failed':raiseRuntimeError(f'{label} failed: {status["code"]}')excepturllib.error.HTTPError:print(' Transaction status: unknown')raiseTimeoutError(f'{label} not confirmed after 60 seconds')# 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 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_multiplier=response_json['medianFeeMultiplier']minimum_multiplier=response_json['minFeeMultiplier']fee_multiplier=max(median_multiplier,minimum_multiplier)print(f' Fee multiplier: {fee_multiplier}')# Embedded tx 1: Account A transfers 10 XYM to Account Bembedded_transaction_1=(facade.create_embedded_transaction_from_descriptor({'type':'transfer_transaction_v1','recipient_address':account_b_address,'mosaics':[{'mosaic_id':generate_mosaic_alias_id('symbol.xym'),'amount':10_000_000# 10 XYM}]},account_a_key_pair.public_key))# Embedded tx 2: Account B transfers 1 custom mosaic to Account Acustom_mosaic_id=0x6D1314BE751B62C2embedded_transaction_2=(facade.create_embedded_transaction_from_descriptor({'type':'transfer_transaction_v1','recipient_address':account_a_address,'mosaics':[{'mosaic_id':custom_mosaic_id,'amount':1# 1 custom mosaic}]},account_b_key_pair.public_key))# Build the aggregate transactionembedded_transactions=[embedded_transaction_1,embedded_transaction_2]transaction=facade.create_transaction_from_descriptor({'type':'aggregate_complete_transaction_v3','transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions},account_a_key_pair.public_key,fee_multiplier,2*60*60,1)print('Built aggregate transaction without signatures:')print(json.dumps(transaction.to_json(),indent=2))# --- ACCOUNT A (Initiator) ---print('[Account A] Signing the aggregate...')signature_a=facade.sign_transaction(account_a_key_pair,transaction)transaction_payload=facade.transaction_factory.attach_signature(transaction,signature_a)payload_formatted=json.dumps(json.loads(transaction_payload),indent=2)print(f'[Account A] Payload ready to share:\n{payload_formatted}')# --- OFF-CHAIN COORDINATION ---# Account A sends the payload to Account Bshared_payload=transaction_payloadprint('[Account A] ==> Payload sent to Account B (offchain)')# --- ACCOUNT B (Cosignatory) ---received_transaction=facade.transaction_factory.deserialize(bytes.fromhex(json.loads(shared_payload)['payload']))print('[Account B] Cosigning...')cosignature_b=facade.cosign_transaction(account_b_key_pair,received_transaction)cosignature_formatted=json.dumps(cosignature_b.to_json(),indent=2)print(f'[Account B] Cosignature created: {cosignature_formatted}')# --- OFF-CHAIN COORDINATION ---# Account B sends the cosignature back to Account Ashared_cosignature=cosignature_bprint('[Account B] <== Cosignature sent back to Account A (offchain)')# --- ACCOUNT A (Initiator) ---# Add cosignature to the transaction and rebuild payloadtransaction.cosignatures.append(shared_cosignature)transaction_payload=facade.transaction_factory.to_json(transaction)json_payload=transaction_payloadprint('[Account A] Ready to announce')# Announce the transactiontransaction_hash=facade.hash_transaction(transaction)print(f'Transaction hash: {transaction_hash}')announce_transaction(json_payload,'transaction')# Wait for confirmationwait_for_confirmation(transaction_hash,'transaction')exceptExceptionase:print(e)
import{PrivateKey}from'symbol-sdk';import{SymbolFacade,descriptors,generateMosaicAliasId,models}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 a transactionasyncfunctionannounceTransaction(payload,label){console.log(`Announcing ${label} to /transactions`);constresponse=awaitfetch(`${NODE_URL}/transactions`,{method:'PUT',headers:{'Content-Type':'application/json'},body:payload});console.log(' Response:',awaitresponse.text());}// Helper function to wait for transaction confirmationasyncfunctionwaitForConfirmation(transactionHash,label){console.log(`Waiting for ${label} confirmation...`);for(letattempt=0;60>attempt;attempt++){awaitnewPromise(resolve=>{setTimeout(resolve,1000);});constresponse=awaitfetch(`${NODE_URL}/transactionStatus/${transactionHash}`);if(!response.ok){if(404===response.status){console.log(' Transaction status: unknown');continue;}thrownewError(`HTTP ${response.status}`);}conststatus=awaitresponse.json();console.log(' Transaction status:',status.group);if('confirmed'===status.group){console.log(`${label} confirmed in`,attempt,'seconds');return;}if('failed'===status.group)thrownewError(`${label} failed: ${status.code}`);}thrownewError(`${label} not confirmed after 60 seconds`);}// 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 recommended feesconstfeePath='/network/fees/transaction';console.log('Fetching recommended fees from',feePath);constfeeResponse=awaitfetch(`${NODE_URL}${feePath}`);constfeeJSON=awaitfeeResponse.json();constmedianMultiplier=feeJSON.medianFeeMultiplier;constminimumMultiplier=feeJSON.minFeeMultiplier;constfeeMultiplier=Math.max(medianMultiplier,minimumMultiplier);console.log(' Fee multiplier:',feeMultiplier);// Embedded tx 1: Account A transfers 10 XYM to Account BconstembeddedTransaction1=facade.createEmbeddedTransactionFromTypedDescriptor(newdescriptors.TransferTransactionV1Descriptor(accountBAddress,[newdescriptors.UnresolvedMosaicDescriptor(generateMosaicAliasId('symbol.xym'),newmodels.Amount(10_000_000n))// 10 XYM],undefined),accountAKeyPair.publicKey);// Embedded tx 2: Account B transfers 1 custom mosaic to Account AconstcustomMosaicId=0x6D1314BE751B62C2n;constembeddedTransaction2=facade.createEmbeddedTransactionFromTypedDescriptor(newdescriptors.TransferTransactionV1Descriptor(accountAAddress,[newdescriptors.UnresolvedMosaicDescriptor(customMosaicId,newmodels.Amount(1n))// 1 custom mosaic],undefined),accountBKeyPair.publicKey);// Build the aggregate transactionconstembeddedTransactions=[embeddedTransaction1,embeddedTransaction2];consttransaction=facade.createTransactionFromTypedDescriptor(newdescriptors.AggregateCompleteTransactionV3Descriptor(facade.static.hashEmbeddedTransactions(embeddedTransactions),embeddedTransactions,undefined),accountAKeyPair.publicKey,feeMultiplier,2*60*60,1);console.log('Built aggregate transaction without signatures:');console.log(JSON.stringify(transaction.toJson(),null,2));// --- ACCOUNT A (Initiator) ---console.log('[Account A] Signing the aggregate...');constsignatureA=facade.signTransaction(accountAKeyPair,transaction);consttransactionPayload=facade.transactionFactory.static.attachSignature(transaction,signatureA);constpayloadFormatted=JSON.stringify(JSON.parse(transactionPayload),null,2);console.log('[Account A] Payload ready to share:\n',payloadFormatted);// --- OFF-CHAIN COORDINATION ---// Account A sends the payload to Account BconstsharedPayload=transactionPayload;console.log('[Account A] ==> Payload sent to Account B (offchain)');// --- ACCOUNT B (Cosignatory) ---constpayloadHex=JSON.parse(sharedPayload).payload;constreceivedTransaction=facade.transactionFactory.static.deserialize(Buffer.from(payloadHex,'hex'));console.log('[Account B] Cosigning...');constcosignatureB=facade.cosignTransaction(accountBKeyPair,receivedTransaction);constcosignatureFormatted=JSON.stringify(cosignatureB.toJson(),null,2);console.log('[Account B] Cosignature created:',cosignatureFormatted);// --- OFF-CHAIN COORDINATION ---// Account B sends the cosignature back to Account AconstsharedCosignature=cosignatureB;console.log('[Account B] <== Cosignature sent back to Account A','(offchain)');// --- ACCOUNT A (Initiator) ---// Add cosignature to the transaction and rebuild payloadtransaction.cosignatures.push(sharedCosignature);consttransactionPayloadFinal=facade.transactionFactory.static.toJson(transaction);constjsonPayload=transactionPayloadFinal;console.log('[Account A] Ready to announce');// Announce the transactionconsttransactionHash=facade.hashTransaction(transaction).toString();console.log('Transaction hash:',transactionHash);awaitannounceTransaction(jsonPayload,'transaction');// Wait for confirmationawaitwaitForConfirmation(transactionHash,'transaction');}catch(e){console.error(e.message,'| Cause:',e.cause?.code??'unknown');}
//JAVA 21+//DEPS org.symbol:symbol-sdk:3.3.1importjava.io.IOException;importjava.net.URI;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.net.http.HttpResponse.BodyHandlers;importjava.util.HexFormat;importjava.util.List;importcom.fasterxml.jackson.databind.JsonNode;importcom.fasterxml.jackson.databind.ObjectMapper;importorg.symbol.sdk.CryptoTypes;importorg.symbol.sdk.facade.SymbolFacade;importorg.symbol.sdk.symbol.Address;importorg.symbol.sdk.symbol.IdGenerator;importorg.symbol.sdk.symbol.KeyPair;importorg.symbol.sdk.symbol.SymbolTransactionFactory;importorg.symbol.sdk.symbol.descriptors.*;importorg.symbol.sdk.symbol.models.*;publicfinalclassCompleteAggregate{privatestaticfinalObjectMapperJSON_MAPPER=newObjectMapper();privatestaticfinalHttpClientHTTP_CLIENT=HttpClient.newHttpClient();privatefinalStringnodeUrl=System.getenv().getOrDefault("NODE_URL","https://reference.symboltest.net:3001");privatefinalSymbolFacadefacade=newSymbolFacade("testnet");privatevoidannounceTransaction(finalStringpayload,finalStringlabel)throwsIOException,InterruptedException{System.out.printf("Announcing %s to /transactions%n",label);finalHttpRequestrequest=HttpRequest.newBuilder(URI.create(nodeUrl+"/transactions")).header("Content-Type","application/json").PUT(HttpRequest.BodyPublishers.ofString(payload)).build();finalHttpResponse<String>response=HTTP_CLIENT.send(request,BodyHandlers.ofString());System.out.printf(" Response: %s%n",response.body());}privatevoidwaitForConfirmation(finalStringtransactionHash,finalStringlabel)throwsIOException,InterruptedException{System.out.printf("Waiting for %s confirmation...%n",label);for(intattempt=0;60>attempt;++attempt){Thread.sleep(1000);finalStringstatusPath="/transactionStatus/"+transactionHash;finalHttpRequeststatusRequest=HttpRequest.newBuilder(URI.create(nodeUrl+statusPath)).GET().build();finalHttpResponse<String>statusResponse=HTTP_CLIENT.send(statusRequest,BodyHandlers.ofString());if(404==statusResponse.statusCode()){System.out.println(" Transaction status: unknown");continue;}if(2!=statusResponse.statusCode()/100)thrownewIOException("HTTP "+statusResponse.statusCode());finalJsonNodestatus=JSON_MAPPER.readTree(statusResponse.body());finalStringgroup=status.get("group").asText();System.out.printf(" Transaction status: %s%n",group);if("confirmed".equals(group)){System.out.printf("%s confirmed in %d seconds%n",label,attempt);return;}if("failed".equals(group))thrownewIOException(String.format("%s failed: %s",label,status.get("code").asText()));}thrownewIOException(String.format("%s not confirmed after 60 seconds",label));}publicstaticvoidmain(finalString[]args){try{newCompleteAggregate().run();}catch(finalExceptionex){System.out.println(null==ex.getMessage()?ex.toString():ex.getMessage());}}privatevoidrun()throwsIOException,InterruptedException{System.out.printf("Using node %s%n",nodeUrl);// Account A (initiates the aggregate tx and sends XYM to// Account B)finalStringaccountAPrivateKey=System.getenv().getOrDefault("ACCOUNT_A_PRIVATE_KEY","0".repeat(64));finalKeyPairaccountAKeyPair=newKeyPair(newCryptoTypes.PrivateKey(accountAPrivateKey));// Account B (sends custom mosaic to Account A)finalStringaccountBPrivateKey=System.getenv().getOrDefault("ACCOUNT_B_PRIVATE_KEY","1".repeat(64));finalKeyPairaccountBKeyPair=newKeyPair(newCryptoTypes.PrivateKey(accountBPrivateKey));finalAddressaccountAAddress=facade.network.publicKeyToAddress(accountAKeyPair.getPublicKey());finalAddressaccountBAddress=facade.network.publicKeyToAddress(accountBKeyPair.getPublicKey());System.out.printf("Account A: %s%n",accountAAddress);System.out.printf("Account B: %s%n",accountBAddress);// Fetch recommended feesfinalStringfeePath="/network/fees/transaction";System.out.printf("Fetching recommended fees from %s%n",feePath);finalHttpRequestfeeRequest=HttpRequest.newBuilder(URI.create(nodeUrl+feePath)).GET().build();finalHttpResponse<String>feeResponse=HTTP_CLIENT.send(feeRequest,BodyHandlers.ofString());finalJsonNodefeeJSON=JSON_MAPPER.readTree(feeResponse.body());finallongmedianMultiplier=feeJSON.get("medianFeeMultiplier").asLong();finallongminimumMultiplier=feeJSON.get("minFeeMultiplier").asLong();finallongfeeMultiplier=Math.max(medianMultiplier,minimumMultiplier);System.out.printf(" Fee multiplier: %d%n",feeMultiplier);// Embedded tx 1: Account A transfers 10 XYM to Account BfinalEmbeddedTransactionembeddedTransaction1=facade.createEmbeddedTransactionFromTypedDescriptor(newTransferTransactionV1Descriptor(accountBAddress,List.of(newUnresolvedMosaicDescriptor(newUnresolvedMosaicId(IdGenerator.generateMosaicAliasId("symbol.xym")),newAmount(10_000_000))),// 10 XYMnull),accountAKeyPair.getPublicKey());// Embedded tx 2: Account B transfers 1 custom mosaic to Account AfinallongcustomMosaicId=0x6D1314BE751B62C2L;finalEmbeddedTransactionembeddedTransaction2=facade.createEmbeddedTransactionFromTypedDescriptor(newTransferTransactionV1Descriptor(accountAAddress,List.of(newUnresolvedMosaicDescriptor(newUnresolvedMosaicId(customMosaicId),newAmount(1))),// 1 custom mosaicnull),accountBKeyPair.getPublicKey());// Build the aggregate transactionfinalList<EmbeddedTransaction>embeddedTransactions=List.of(embeddedTransaction1,embeddedTransaction2);finalAggregateCompleteTransactionV3transaction=(AggregateCompleteTransactionV3)facade.createTransactionFromTypedDescriptor(newAggregateCompleteTransactionV3Descriptor(SymbolFacade.hashEmbeddedTransactions(embeddedTransactions),embeddedTransactions,null),accountAKeyPair.getPublicKey(),feeMultiplier,2*60*60,1);System.out.println("Built aggregate transaction without signatures:");System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(transaction.toJson()));// --- ACCOUNT A (Initiator) ---System.out.println("[Account A] Signing the aggregate...");finalCryptoTypes.SignaturesignatureA=facade.signTransaction(accountAKeyPair,transaction);finalStringtransactionPayload=SymbolTransactionFactory.attachSignature(transaction,signatureA);finalStringpayloadFormatted=JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(JSON_MAPPER.readTree(transactionPayload));System.out.printf("[Account A] Payload ready to share:%n %s%n",payloadFormatted);// --- OFF-CHAIN COORDINATION ---// Account A sends the payload to Account BfinalStringsharedPayload=transactionPayload;System.out.println("[Account A] ==> Payload sent to Account B (offchain)");// --- ACCOUNT B (Cosignatory) ---finalStringpayloadHex=JSON_MAPPER.readTree(sharedPayload).get("payload").asText();finalTransactionreceivedTransaction=SymbolTransactionFactory.deserialize(HexFormat.of().parseHex(payloadHex));System.out.println("[Account B] Cosigning...");finalCosignaturecosignatureB=facade.cosignTransaction(accountBKeyPair,receivedTransaction);finalStringcosignatureFormatted=JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(cosignatureB.toJson());System.out.printf("[Account B] Cosignature created: %s%n",cosignatureFormatted);// --- OFF-CHAIN COORDINATION ---// Account B sends the cosignature back to Account AfinalCosignaturesharedCosignature=cosignatureB;System.out.println("[Account B] <== Cosignature sent back "+"to Account A (offchain)");// --- ACCOUNT A (Initiator) ---// Add cosignature to the transaction and rebuild payloadtransaction.getCosignatures().add(sharedCosignature);finalStringtransactionPayloadFinal=SymbolTransactionFactory.toJson(transaction);finalStringjsonPayload=transactionPayloadFinal;System.out.println("[Account A] Ready to announce");// Announce the transactionfinalStringtransactionHash=facade.hashTransaction(transaction).toString();System.out.printf("Transaction hash: %s%n",transactionHash);announceTransaction(jsonPayload,"transaction");// Wait for confirmationwaitForConfirmation(transactionHash,"transaction");}}
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.
A complete aggregate transaction may involve two distinct roles: an initiator (Account A) that builds and announces
the aggregate, and one or more cosigners (Account B, and any additional cosigners) that receive the transaction
payload off-chain and add their signatures after verifying the transaction.
When only one account is involved, no cosignatures are needed.
In practice, each role runs as a separate program on a separate machine.
This tutorial demonstrates the multi-party case but combines both roles in a single script for simplicity.
# 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());
// Account A (initiates the aggregate tx and sends XYM to// Account B)finalStringaccountAPrivateKey=System.getenv().getOrDefault("ACCOUNT_A_PRIVATE_KEY","0".repeat(64));finalKeyPairaccountAKeyPair=newKeyPair(newCryptoTypes.PrivateKey(accountAPrivateKey));// Account B (sends custom mosaic to Account A)finalStringaccountBPrivateKey=System.getenv().getOrDefault("ACCOUNT_B_PRIVATE_KEY","1".repeat(64));finalKeyPairaccountBKeyPair=newKeyPair(newCryptoTypes.PrivateKey(accountBPrivateKey));finalAddressaccountAAddress=facade.network.publicKeyToAddress(accountAKeyPair.getPublicKey());finalAddressaccountBAddress=facade.network.publicKeyToAddress(accountBKeyPair.getPublicKey());System.out.printf("Account A: %s%n",accountAAddress);System.out.printf("Account B: %s%n",accountBAddress);
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.
// Embedded tx 1: Account A transfers 10 XYM to Account BfinalEmbeddedTransactionembeddedTransaction1=facade.createEmbeddedTransactionFromTypedDescriptor(newTransferTransactionV1Descriptor(accountBAddress,List.of(newUnresolvedMosaicDescriptor(newUnresolvedMosaicId(IdGenerator.generateMosaicAliasId("symbol.xym")),newAmount(10_000_000))),// 10 XYMnull),accountAKeyPair.getPublicKey());// Embedded tx 2: Account B transfers 1 custom mosaic to Account AfinallongcustomMosaicId=0x6D1314BE751B62C2L;finalEmbeddedTransactionembeddedTransaction2=facade.createEmbeddedTransactionFromTypedDescriptor(newTransferTransactionV1Descriptor(accountAAddress,List.of(newUnresolvedMosaicDescriptor(newUnresolvedMosaicId(customMosaicId),newAmount(1))),// 1 custom mosaicnull),accountBKeyPair.getPublicKey());
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 TransferTransactionV1, 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 aggregate transactionembedded_transactions=[embedded_transaction_1,embedded_transaction_2]transaction=facade.create_transaction_from_descriptor({'type':'aggregate_complete_transaction_v3','transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions},account_a_key_pair.public_key,fee_multiplier,2*60*60,1)print('Built aggregate transaction without signatures:')print(json.dumps(transaction.to_json(),indent=2))
// Build the aggregate transactionconstembeddedTransactions=[embeddedTransaction1,embeddedTransaction2];consttransaction=facade.createTransactionFromTypedDescriptor(newdescriptors.AggregateCompleteTransactionV3Descriptor(facade.static.hashEmbeddedTransactions(embeddedTransactions),embeddedTransactions,undefined),accountAKeyPair.publicKey,feeMultiplier,2*60*60,1);console.log('Built aggregate transaction without signatures:');console.log(JSON.stringify(transaction.toJson(),null,2));
// Build the aggregate transactionfinalList<EmbeddedTransaction>embeddedTransactions=List.of(embeddedTransaction1,embeddedTransaction2);finalAggregateCompleteTransactionV3transaction=(AggregateCompleteTransactionV3)facade.createTransactionFromTypedDescriptor(newAggregateCompleteTransactionV3Descriptor(SymbolFacade.hashEmbeddedTransactions(embeddedTransactions),embeddedTransactions,null),accountAKeyPair.getPublicKey(),feeMultiplier,2*60*60,1);System.out.println("Built aggregate transaction without signatures:");System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(transaction.toJson()));
Once the embedded transactions are prepared, create the complete aggregate transaction from the transaction's descriptor.
The descriptor contains:
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.
also receives the signer public key, fee multiplier, deadline
duration, and reserved cosignature count.
The signer initiates the aggregate, announces the transaction, and pays the transaction fee.
calculates the fee based on the aggregate's total size,
which includes all embedded transactions plus space reserved for one cosignature.
The reserved cosignature count is provided as the final argument, set to 1 here.
# --- ACCOUNT A (Initiator) ---print('[Account A] Signing the aggregate...')signature_a=facade.sign_transaction(account_a_key_pair,transaction)transaction_payload=facade.transaction_factory.attach_signature(transaction,signature_a)payload_formatted=json.dumps(json.loads(transaction_payload),indent=2)print(f'[Account A] Payload ready to share:\n{payload_formatted}')# --- OFF-CHAIN COORDINATION ---# Account A sends the payload to Account Bshared_payload=transaction_payloadprint('[Account A] ==> Payload sent to Account B (offchain)')
// --- ACCOUNT A (Initiator) ---console.log('[Account A] Signing the aggregate...');constsignatureA=facade.signTransaction(accountAKeyPair,transaction);consttransactionPayload=facade.transactionFactory.static.attachSignature(transaction,signatureA);constpayloadFormatted=JSON.stringify(JSON.parse(transactionPayload),null,2);console.log('[Account A] Payload ready to share:\n',payloadFormatted);// --- OFF-CHAIN COORDINATION ---// Account A sends the payload to Account BconstsharedPayload=transactionPayload;console.log('[Account A] ==> Payload sent to Account B (offchain)');
// --- ACCOUNT A (Initiator) ---System.out.println("[Account A] Signing the aggregate...");finalCryptoTypes.SignaturesignatureA=facade.signTransaction(accountAKeyPair,transaction);finalStringtransactionPayload=SymbolTransactionFactory.attachSignature(transaction,signatureA);finalStringpayloadFormatted=JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(JSON_MAPPER.readTree(transactionPayload));System.out.printf("[Account A] Payload ready to share:%n %s%n",payloadFormatted);// --- OFF-CHAIN COORDINATION ---// Account A sends the payload to Account BfinalStringsharedPayload=transactionPayload;System.out.println("[Account A] ==> Payload sent to Account B (offchain)");
Account A signs the transaction using and produces an intermediate payload using
.
This payload is not yet ready to announce because it is missing Account B's cosignature.
Account A sends this intermediate payload to Account B through an off-chain channel.
Signatures in aggregate transactions
An account only signs once, even if it appears as the signer in multiple embedded transactions.
In this tutorial, Account A signs the aggregate transaction, which covers both the aggregate itself and the
first embedded transaction where Account A is the signer.
When all embedded transactions share the same signer (batching multiple operations from one account),
cosignatures are not required. The aggregate can be announced immediately after signing, and the fee
calculation does not need to reserve space for cosignatures.
# --- ACCOUNT B (Cosignatory) ---received_transaction=facade.transaction_factory.deserialize(bytes.fromhex(json.loads(shared_payload)['payload']))print('[Account B] Cosigning...')cosignature_b=facade.cosign_transaction(account_b_key_pair,received_transaction)cosignature_formatted=json.dumps(cosignature_b.to_json(),indent=2)print(f'[Account B] Cosignature created: {cosignature_formatted}')# --- OFF-CHAIN COORDINATION ---# Account B sends the cosignature back to Account Ashared_cosignature=cosignature_bprint('[Account B] <== Cosignature sent back to Account A (offchain)')
// --- ACCOUNT B (Cosignatory) ---constpayloadHex=JSON.parse(sharedPayload).payload;constreceivedTransaction=facade.transactionFactory.static.deserialize(Buffer.from(payloadHex,'hex'));console.log('[Account B] Cosigning...');constcosignatureB=facade.cosignTransaction(accountBKeyPair,receivedTransaction);constcosignatureFormatted=JSON.stringify(cosignatureB.toJson(),null,2);console.log('[Account B] Cosignature created:',cosignatureFormatted);// --- OFF-CHAIN COORDINATION ---// Account B sends the cosignature back to Account AconstsharedCosignature=cosignatureB;console.log('[Account B] <== Cosignature sent back to Account A','(offchain)');
// --- ACCOUNT B (Cosignatory) ---finalStringpayloadHex=JSON_MAPPER.readTree(sharedPayload).get("payload").asText();finalTransactionreceivedTransaction=SymbolTransactionFactory.deserialize(HexFormat.of().parseHex(payloadHex));System.out.println("[Account B] Cosigning...");finalCosignaturecosignatureB=facade.cosignTransaction(accountBKeyPair,receivedTransaction);finalStringcosignatureFormatted=JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(cosignatureB.toJson());System.out.printf("[Account B] Cosignature created: %s%n",cosignatureFormatted);// --- OFF-CHAIN COORDINATION ---// Account B sends the cosignature back to Account AfinalCosignaturesharedCosignature=cosignatureB;System.out.println("[Account B] <== Cosignature sent back "+"to Account A (offchain)");
Account B receives the payload and deserializes it using to reconstruct the
transaction object.
Account B should verify that the embedded transactions match what it expects to sign.
It then cosigns using , which computes the transaction hash and produces a
cosignature object.
Only this cosignature is sent back to Account A.
Verify before cosigning
Always inspect transaction content before cosigning.
Cosignatures are binding and cannot be undone.
# --- ACCOUNT A (Initiator) ---# Add cosignature to the transaction and rebuild payloadtransaction.cosignatures.append(shared_cosignature)transaction_payload=facade.transaction_factory.to_json(transaction)json_payload=transaction_payloadprint('[Account A] Ready to announce')
// --- ACCOUNT A (Initiator) ---// Add cosignature to the transaction and rebuild payloadtransaction.cosignatures.push(sharedCosignature);consttransactionPayloadFinal=facade.transactionFactory.static.toJson(transaction);constjsonPayload=transactionPayloadFinal;console.log('[Account A] Ready to announce');
// --- ACCOUNT A (Initiator) ---// Add cosignature to the transaction and rebuild payloadtransaction.getCosignatures().add(sharedCosignature);finalStringtransactionPayloadFinal=SymbolTransactionFactory.toJson(transaction);finalStringjsonPayload=transactionPayloadFinal;System.out.println("[Account A] Ready to announce");
Account A receives Account B's cosignature, adds it to the transaction object's cosignatures array, and rebuilds the
payload for announcement.
Now that the transaction is ready to be announced, it follows the same process as regular, non-aggregate transactions,
as shown in the Transfer Transaction tutorial.
// Announce the transactionconsttransactionHash=facade.hashTransaction(transaction).toString();console.log('Transaction hash:',transactionHash);awaitannounceTransaction(jsonPayload,'transaction');
// Announce the transactionfinalStringtransactionHash=facade.hashTransaction(transaction).toString();System.out.printf("Transaction hash: %s%n",transactionHash);announceTransaction(jsonPayload,"transaction");
Once all signatures are collected, the transaction is announced to a node using the /transactionsPUT endpoint.
The node validates that all required signatures are present and valid before accepting the transaction.
If validation passes, the transaction is added to the unconfirmed pool and broadcast to other nodes.
The polling loop checks the status every second until the transaction is confirmed or fails.
Once confirmed, the swap is complete and both transfers have executed.
Line 16 ("transactions"): Contains the two embedded transfers that will execute atomically.
Line 46 ("cosignatures": []): Initially empty. Account B's cosignature is added before announcement.
Note how Account A's signature is only needed once, even though it appears as signer in both the aggregate and the
first embedded transaction.
Line 51 ("payload": "6801..."): The transaction payload computed from the aggregate transaction and its embedded
transactions.
Line 58 ("signature": "7037..."): Account B's cosignature for the aggregate transaction.
Line 62 (Transaction hash): The hash can be used to search for the
transaction in the Symbol Testnet Explorer.
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.
Batch from one account: If all transactions share the same signer and no cosignatures are needed, see the
Batching Transactions tutorial for a simpler flow.