Additionally, review the Transfer transaction tutorial to understand how
transactions are announced and confirmed, and the
Complete Aggregate transaction tutorial to understand how aggregate
transactions work.
importjsonimportosimporttimeimporturllib.requestfromsymbolchain.CryptoTypesimportPrivateKeyfromsymbolchain.facade.SymbolFacadeimportSymbolFacadefromsymbolchain.scimportAmountfromsymbolchain.symbol.Metadataimport(metadata_generate_key,metadata_update_value)fromsymbolchain.symbol.NetworkimportNetworkTimestampNODE_URL=os.environ.get('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)asresponse:print(f' Response: {response.read().decode()}')# Helper function to wait for transaction confirmationdefwait_for_confirmation(transaction_hash,label):print(f'Waiting for {label} confirmation...')forattemptinrange(60):time.sleep(1)try:url=f'{NODE_URL}/transactionStatus/{transaction_hash}'withurllib.request.urlopen(url)asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status["group"]}')ifstatus['group']=='confirmed':print(f'{label} confirmed in {attempt} seconds')returnifstatus['group']=='failed':raiseException(f'{label} failed: {status["code"]}')excepturllib.error.HTTPError:print(' Transaction status: unknown')raiseException(f'{label} not confirmed after 60 seconds')SIGNER_PRIVATE_KEY=os.getenv('SIGNER_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000000')signer_key_pair=SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))facade=SymbolFacade('testnet')signer_address=facade.network.public_key_to_address(signer_key_pair.public_key)print(f'Signer address: {signer_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}')# --- ADDING NEW METADATA ---print('\n--- Adding new metadata ---')# Define metadata key and valuekey_string=f'username_{int(time.time())}'scoped_metadata_key=metadata_generate_key(key_string)metadata_value='alice'.encode('utf8')# Create the embedded metadata transactionembedded_transaction=facade.transaction_factory.create_embedded({'type':'account_metadata_transaction_v1','signer_public_key':signer_key_pair.public_key,'target_address':signer_address,'scoped_metadata_key':scoped_metadata_key,# When creating new metadata, value_size_delta# equals the value length'value_size_delta':len(metadata_value),'value':metadata_value})print('Created embedded metadata transaction:')print(json.dumps(embedded_transaction.to_json(),indent=2))# Build the aggregate transactionembedded_transactions=[embedded_transaction]transaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3','signer_public_key':signer_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})transaction.fee=Amount(fee_mult*transaction.size)# Sign and generate final payloadsignature=facade.sign_transaction(signer_key_pair,transaction)json_payload=facade.transaction_factory.attach_signature(transaction,signature)# Announce and wait for confirmationtransaction_hash=facade.hash_transaction(transaction)print(f'Built aggregate transaction with hash: {transaction_hash}')announce_transaction(json_payload,'aggregate transaction')wait_for_confirmation(transaction_hash,'aggregate transaction')# --- MODIFYING EXISTING METADATA ---print('\n--- Modifying existing metadata ---')# Fetch current metadata value from networkmetadata_path=(f'/metadata?sourceAddress={signer_address}'f'&targetAddress={signer_address}'f'&scopedMetadataKey={scoped_metadata_key:016X}''&metadataType=0')print(f'Fetching current metadata from {metadata_path}')withurllib.request.urlopen(f'{NODE_URL}{metadata_path}')asresponse:response_json=json.loads(response.read().decode())# Get the metadata entryifnotresponse_json['data']:raiseException('Metadata entry not found')metadata_entry=response_json['data'][0]['metadataEntry']current_value=bytes.fromhex(metadata_entry['value'])print(f' Current value: {current_value.decode("utf8")}')# XOR the current and new valuesnew_value='bob'.encode('utf8')update_value=metadata_update_value(current_value,new_value)# Create the update transaction with XOR'd valueembedded_update=facade.transaction_factory.create_embedded({'type':'account_metadata_transaction_v1','signer_public_key':signer_key_pair.public_key,'target_address':signer_address,'scoped_metadata_key':scoped_metadata_key,# value_size_delta is the difference in length# (can be negative)'value_size_delta':len(new_value)-len(current_value),'value':update_value})# Build the aggregate for the updateembedded_transactions=[embedded_update]update_transaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3','signer_public_key':signer_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})update_transaction.fee=Amount(fee_mult*update_transaction.size)# Sign and announce the updatesignature=facade.sign_transaction(signer_key_pair,update_transaction)json_payload=facade.transaction_factory.attach_signature(update_transaction,signature)# Announce and wait for confirmationupdate_hash=facade.hash_transaction(update_transaction)print(f'Built aggregate transaction with hash: {update_hash}')announce_transaction(json_payload,'aggregate transaction')wait_for_confirmation(update_hash,'aggregate transaction')exceptExceptionase:print(e)
import{PrivateKey}from'symbol-sdk';import{metadataGenerateKey,metadataUpdateValue,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 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;attempt<60;attempt++){awaitnewPromise(resolve=>setTimeout(resolve,1000));try{constresponse=awaitfetch(`${NODE_URL}/transactionStatus/${transactionHash}`);conststatus=awaitresponse.json();console.log(' Transaction status:',status.group);if(status.group==='confirmed'){console.log(`${label} confirmed in`,attempt,'seconds');return;}if(status.group==='failed'){thrownewError(`${label} failed: ${status.code}`);}}catch(e){if(e.message.includes('failed'))throwe;console.log(' Transaction status: unknown');}}thrownewError(`${label} not confirmed after 60 seconds`);}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 address:',signerAddress.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);// --- ADDING NEW METADATA ---console.log('\n--- Adding new metadata ---');// Define metadata key and valueconstkeyString=`username_${Date.now()}`;constscopedMetadataKey=metadataGenerateKey(keyString);constmetadataValue=newTextEncoder().encode('alice');// Create the embedded metadata transactionconstembeddedTransaction=facade.transactionFactory.createEmbedded({type:'account_metadata_transaction_v1',signerPublicKey:signerKeyPair.publicKey.toString(),targetAddress:signerAddress.toString(),scopedMetadataKey,// When creating new metadata, valueSizeDelta// equals value lengthvalueSizeDelta:metadataValue.length,value:metadataValue});console.log('Created embedded metadata transaction:');console.log(JSON.stringify(embeddedTransaction.toJson(),null,2));// Build the aggregate transactionconstembeddedTransactions=[embeddedTransaction];consttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',signerPublicKey:signerKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(embeddedTransactions),transactions:embeddedTransactions});transaction.fee=newmodels.Amount(feeMult*transaction.size);// Sign and generate final payloadconstsignature=facade.signTransaction(signerKeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);// Announce and wait for confirmationconsttransactionHash=facade.hashTransaction(transaction).toString();console.log('Built aggregate transaction with hash:',transactionHash);awaitannounceTransaction(jsonPayload,'aggregate transaction');awaitwaitForConfirmation(transactionHash,'aggregate transaction');// --- MODIFYING EXISTING METADATA ---console.log('\n--- Modifying existing metadata ---');// Fetch current metadata value from networkconstscopedKeyHex=scopedMetadataKey.toString(16).toUpperCase().padStart(16,'0');constmetadataPath=`/metadata?sourceAddress=${signerAddress}`+`&targetAddress=${signerAddress}`+`&scopedMetadataKey=${scopedKeyHex}`+'&metadataType=0';console.log('Fetching current metadata from',metadataPath);constmetadataResponse=awaitfetch(`${NODE_URL}${metadataPath}`);constmetadataJSON=awaitmetadataResponse.json();// Get the metadata entryif(!metadataJSON.data.length){thrownewError('Metadata entry not found');}constmetadataEntry=metadataJSON.data[0].metadataEntry;constcurrentValue=Buffer.from(metadataEntry.value,'hex');console.log(' Current value:',currentValue.toString('utf8'));// XOR the current and new valuesconstnewValue=newTextEncoder().encode('bob');constupdateValue=metadataUpdateValue(currentValue,newValue);// Create the update transaction with XOR'd valueconstembeddedUpdate=facade.transactionFactory.createEmbedded({type:'account_metadata_transaction_v1',signerPublicKey:signerKeyPair.publicKey.toString(),targetAddress:signerAddress.toString(),scopedMetadataKey,// valueSizeDelta is the difference in length// (can be negative)valueSizeDelta:newValue.length-currentValue.length,value:updateValue});// Build the aggregate for the updateconstupdateEmbedded=[embeddedUpdate];constupdateTransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',signerPublicKey:signerKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(updateEmbedded),transactions:updateEmbedded});updateTransaction.fee=newmodels.Amount(feeMult*updateTransaction.size);// Sign and announce the updateconstupdateSignature=facade.signTransaction(signerKeyPair,updateTransaction);constupdatePayload=facade.transactionFactory.static.attachSignature(updateTransaction,updateSignature);// Announce and wait for confirmationconstupdateHash=facade.hashTransaction(updateTransaction).toString();console.log('Built aggregate transaction with hash:',updateHash);awaitannounceTransaction(updatePayload,'aggregate transaction');awaitwaitForConfirmation(updateHash,'aggregate transaction');}catch(e){console.error(e.message,'| Cause:',e.cause?.code??'unknown');}
The snippet reads the signer's private key from the SIGNER_PRIVATE_KEY environment variable, which defaults to a
test key if not set.
The signer's address is derived from the public key.
In this tutorial, the signer adds metadata to their own account.
Adding metadata to a different account requires the target to cosign the transaction.
# 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}')
// Define metadata key and valueconstkeyString=`username_${Date.now()}`;constscopedMetadataKey=metadataGenerateKey(keyString);constmetadataValue=newTextEncoder().encode('alice');
Each metadata entry is uniquely identified by the signer's address, the target account's address, and a
scoped metadata key: a 64-bit value chosen by the metadata creator.
Multiple entries with the same key
Because the signer's address is part of the unique identifier, different accounts can use the same scoped metadata
key on the same target account without conflict.
For example, Account A and Account B can both use the key username when adding metadata to Account C,
resulting in two distinct metadata entries.
Each entry is independent and can only be updated by the account that originally created it.
The SDK provides a helper function that generates a key from a
human-readable string using SHA3-256 hashing.
This approach makes keys more meaningful and reduces the chance of collisions.
In this example, the key is derived from the string username.
For demonstration purposes, a timestamp is appended to the key string,
so each time the code is executed a new entry is added to the account.
In practice, you would use a fixed key that identifies the specific metadata entry you want to create or update.
The metadata value can be any byte sequence.
In this example, the value is the string alice encoded in UTF-8.
Creating the Embedded Account Metadata Transaction⚓︎
# Create the embedded metadata transactionembedded_transaction=facade.transaction_factory.create_embedded({'type':'account_metadata_transaction_v1','signer_public_key':signer_key_pair.public_key,'target_address':signer_address,'scoped_metadata_key':scoped_metadata_key,# When creating new metadata, value_size_delta# equals the value length'value_size_delta':len(metadata_value),'value':metadata_value})print('Created embedded metadata transaction:')print(json.dumps(embedded_transaction.to_json(),indent=2))
// Create the embedded metadata transactionconstembeddedTransaction=facade.transactionFactory.createEmbedded({type:'account_metadata_transaction_v1',signerPublicKey:signerKeyPair.publicKey.toString(),targetAddress:signerAddress.toString(),scopedMetadataKey,// When creating new metadata, valueSizeDelta// equals value lengthvalueSizeDelta:metadataValue.length,value:metadataValue});console.log('Created embedded metadata transaction:');console.log(JSON.stringify(embeddedTransaction.toJson(),null,2));
An account metadata transaction attaches a key-value pair to an account on the blockchain.
The same transaction type handles both adding new metadata entries and updating existing ones.
Symbol requires these transactions to be inside an aggregate transaction that includes the target account owner's
signature.
This prevents unwanted metadata to be attached to an account without its owner's permission.
An aggregate is still required even when the transaction is initiated by the account owner,
to keep the transaction format uniform.
For this reason, the code defines the account metadata transaction as an embedded transaction.
This transaction specifies:
Type: Use account_metadata_transaction_v1.
Signer public key: The account creating the metadata entry.
In this case, this is the account receiving the metadata too.
Target address: The account to attach the metadata to.
When the target differs from the signer, the target account must cosign the aggregate transaction.
Scoped metadata key: The 64-bit key used to identify this metadata entry.
Value size delta: When creating new metadata, set this to the byte length of the value.
When updating existing metadata, set this to the difference between the new and current value lengths.
Value: The metadata content as bytes.
When creating new metadata, provide the raw value.
When updating, provide a computed value (explained in the
Modifying Existing Metadata section).
# Build the aggregate transactionembedded_transactions=[embedded_transaction]transaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3','signer_public_key':signer_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})transaction.fee=Amount(fee_mult*transaction.size)
// Build the aggregate transactionconstembeddedTransactions=[embeddedTransaction];consttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',signerPublicKey:signerKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(embeddedTransactions),transactions:embeddedTransactions});transaction.fee=newmodels.Amount(feeMult*transaction.size);
The code adds the embedded account metadata transaction to an aggregate transaction.
Since the signer is modifying their own account, no cosignatures are required and the aggregate can be created as
complete, allowing it to be signed and announced immediately.
Adding metadata to a different account
If the target account is different from the signer, the target must cosign the aggregate transaction to approve the
metadata entry.
For details on collecting cosignatures on-chain, see the Bonded Aggregate
tutorial.
# Sign and generate final payloadsignature=facade.sign_transaction(signer_key_pair,transaction)json_payload=facade.transaction_factory.attach_signature(transaction,signature)# Announce and wait for confirmationtransaction_hash=facade.hash_transaction(transaction)print(f'Built aggregate transaction with hash: {transaction_hash}')announce_transaction(json_payload,'aggregate transaction')wait_for_confirmation(transaction_hash,'aggregate transaction')
// Sign and generate final payloadconstsignature=facade.signTransaction(signerKeyPair,transaction);constjsonPayload=facade.transactionFactory.static.attachSignature(transaction,signature);// Announce and wait for confirmationconsttransactionHash=facade.hashTransaction(transaction).toString();console.log('Built aggregate transaction with hash:',transactionHash);awaitannounceTransaction(jsonPayload,'aggregate transaction');awaitwaitForConfirmation(transactionHash,'aggregate transaction');
# Fetch current metadata value from networkmetadata_path=(f'/metadata?sourceAddress={signer_address}'f'&targetAddress={signer_address}'f'&scopedMetadataKey={scoped_metadata_key:016X}''&metadataType=0')print(f'Fetching current metadata from {metadata_path}')withurllib.request.urlopen(f'{NODE_URL}{metadata_path}')asresponse:response_json=json.loads(response.read().decode())# Get the metadata entryifnotresponse_json['data']:raiseException('Metadata entry not found')metadata_entry=response_json['data'][0]['metadataEntry']current_value=bytes.fromhex(metadata_entry['value'])print(f' Current value: {current_value.decode("utf8")}')
// Fetch current metadata value from networkconstscopedKeyHex=scopedMetadataKey.toString(16).toUpperCase().padStart(16,'0');constmetadataPath=`/metadata?sourceAddress=${signerAddress}`+`&targetAddress=${signerAddress}`+`&scopedMetadataKey=${scopedKeyHex}`+'&metadataType=0';console.log('Fetching current metadata from',metadataPath);constmetadataResponse=awaitfetch(`${NODE_URL}${metadataPath}`);constmetadataJSON=awaitmetadataResponse.json();// Get the metadata entryif(!metadataJSON.data.length){thrownewError('Metadata entry not found');}constmetadataEntry=metadataJSON.data[0].metadataEntry;constcurrentValue=Buffer.from(metadataEntry.value,'hex');console.log(' Current value:',currentValue.toString('utf8'));
Updating an existing metadata entry requires the current value from the network.
The code queries the /metadataGET endpoint with filters for sourceAddress, targetAddress,
scopedMetadataKey, and metadataType (0 for account metadata) to retrieve the specific entry.
# XOR the current and new valuesnew_value='bob'.encode('utf8')update_value=metadata_update_value(current_value,new_value)# Create the update transaction with XOR'd valueembedded_update=facade.transaction_factory.create_embedded({'type':'account_metadata_transaction_v1','signer_public_key':signer_key_pair.public_key,'target_address':signer_address,'scoped_metadata_key':scoped_metadata_key,# value_size_delta is the difference in length# (can be negative)'value_size_delta':len(new_value)-len(current_value),'value':update_value})# Build the aggregate for the updateembedded_transactions=[embedded_update]update_transaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3','signer_public_key':signer_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions(embedded_transactions),'transactions':embedded_transactions})update_transaction.fee=Amount(fee_mult*update_transaction.size)
// XOR the current and new valuesconstnewValue=newTextEncoder().encode('bob');constupdateValue=metadataUpdateValue(currentValue,newValue);// Create the update transaction with XOR'd valueconstembeddedUpdate=facade.transactionFactory.createEmbedded({type:'account_metadata_transaction_v1',signerPublicKey:signerKeyPair.publicKey.toString(),targetAddress:signerAddress.toString(),scopedMetadataKey,// valueSizeDelta is the difference in length// (can be negative)valueSizeDelta:newValue.length-currentValue.length,value:updateValue});// Build the aggregate for the updateconstupdateEmbedded=[embeddedUpdate];constupdateTransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',signerPublicKey:signerKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions(updateEmbedded),transactions:updateEmbedded});updateTransaction.fee=newmodels.Amount(feeMult*updateTransaction.size);
To demonstrate updating metadata, the code changes the username from alice to bob using another account
metadata transaction.
Updating metadata in Symbol requires:
value_size_delta: The difference in length between the new and current values.
In this example, the delta is -2 because bob (3 bytes) is shorter than alice (5 bytes).
value: The XOR'd bytes computed by comparing the current and new values byte-by-byte.
The SDK provides a helper function that handles the XOR calculation.
The XOR operation compares each byte: matching bytes become zero, and differing bytes capture the change.
Note that value_size_delta represents the difference in final value lengths (new vs current),
not the length of the XOR'd bytes themselves.
# Sign and announce the updatesignature=facade.sign_transaction(signer_key_pair,update_transaction)json_payload=facade.transaction_factory.attach_signature(update_transaction,signature)# Announce and wait for confirmationupdate_hash=facade.hash_transaction(update_transaction)print(f'Built aggregate transaction with hash: {update_hash}')announce_transaction(json_payload,'aggregate transaction')wait_for_confirmation(update_hash,'aggregate transaction')
// Sign and announce the updateconstupdateSignature=facade.signTransaction(signerKeyPair,updateTransaction);constupdatePayload=facade.transactionFactory.static.attachSignature(updateTransaction,updateSignature);// Announce and wait for confirmationconstupdateHash=facade.hashTransaction(updateTransaction).toString();console.log('Built aggregate transaction with hash:',updateHash);awaitannounceTransaction(updatePayload,'aggregate transaction');awaitwaitForConfirmation(updateHash,'aggregate transaction');