Skip to content

Adding Metadata to an Account⚓︎

Accounts can store metadata as key-value pairs.

This tutorial shows how to add metadata to an account, retrieve metadata from the network, and update existing values.

In this example, the string alice is attached to an account and then changed to bob.

Prerequisites⚓︎

Before you start, make sure to:

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.

Full Code⚓︎

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.sc import Amount
from symbolchain.symbol.Metadata import (
    metadata_generate_key,
    metadata_update_value
)
from symbolchain.symbol.Network import NetworkTimestamp

NODE_URL = os.environ.get(
    'NODE_URL', 'https://reference.symboltest.net:3001')
print(f'Using node {NODE_URL}')


# Helper function to announce a transaction
def announce_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'
    )
    with urllib.request.urlopen(request) as response:
        print(f'  Response: {response.read().decode()}')


# Helper function to wait for transaction confirmation
def wait_for_confirmation(transaction_hash, label):
    print(f'Waiting for {label} confirmation...')
    for attempt in range(60):
        time.sleep(1)
        try:
            url = f'{NODE_URL}/transactionStatus/{transaction_hash}'
            with urllib.request.urlopen(url) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status["group"]}')
                if status['group'] == 'confirmed':
                    print(f'{label} confirmed in {attempt} seconds')
                    return
                if status['group'] == 'failed':
                    raise Exception(f'{label} failed: {status["code"]}')
        except urllib.error.HTTPError:
            print('  Transaction status: unknown')
    raise Exception(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 time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        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 fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        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 value
    key_string = f'username_{int(time.time())}'
    scoped_metadata_key = metadata_generate_key(key_string)
    metadata_value = 'alice'.encode('utf8')

    # Create the embedded metadata transaction
    embedded_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 transaction
    embedded_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 payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)

    # Announce and wait for confirmation
    transaction_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 network
    metadata_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}')
    with urllib.request.urlopen(
            f'{NODE_URL}{metadata_path}') as response:
        response_json = json.loads(response.read().decode())

    # Get the metadata entry
    if not response_json['data']:
        raise Exception('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 values
    new_value = 'bob'.encode('utf8')
    update_value = metadata_update_value(current_value, new_value)

    # Create the update transaction with XOR'd value
    embedded_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 update
    embedded_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 update
    signature = facade.sign_transaction(
        signer_key_pair, update_transaction)
    json_payload = facade.transaction_factory.attach_signature(
        update_transaction, signature)

    # Announce and wait for confirmation
    update_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')

except Exception as e:
    print(e)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    metadataGenerateKey,
    metadataUpdateValue,
    models,
    NetworkTimestamp,
    SymbolFacade
} from 'symbol-sdk/symbol';

const NODE_URL = process.env.NODE_URL ||
    'https://reference.symboltest.net:3001';
console.log('Using node', NODE_URL);

// Helper function to announce a transaction
async function announceTransaction(payload, label) {
    console.log(`Announcing ${label} to /transactions`);
    const response = await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    console.log('  Response:', await response.text());
}

// Helper function to wait for transaction confirmation
async function waitForConfirmation(transactionHash, label) {
    console.log(`Waiting for ${label} confirmation...`);
    for (let attempt = 0; attempt < 60; attempt++) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        try {
            const response = await fetch(
                `${NODE_URL}/transactionStatus/${transactionHash}`);
            const status = await response.json();
            console.log('  Transaction status:', status.group);
            if (status.group === 'confirmed') {
                console.log(`${label} confirmed in`, attempt, 'seconds');
                return;
            }
            if (status.group === 'failed') {
                throw new Error(`${label} failed: ${status.code}`);
            }
        } catch (e) {
            if (e.message.includes('failed'))
                throw e;
            console.log('  Transaction status: unknown');
        }
    }
    throw new Error(`${label} not confirmed after 60 seconds`);
}

const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000000');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
const signerAddress = facade.network.publicKeyToAddress(
    signerKeyPair.publicKey);
console.log('Signer address:', signerAddress.toString());

try {
    // Fetch current network time
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

    // --- ADDING NEW METADATA ---
    console.log('\n--- Adding new metadata ---');

    // Define metadata key and value
    const keyString = `username_${Date.now()}`;
    const scopedMetadataKey = metadataGenerateKey(keyString);
    const metadataValue = new TextEncoder().encode('alice');

    // Create the embedded metadata transaction
    const embeddedTransaction = facade.transactionFactory
        .createEmbedded({
            type: 'account_metadata_transaction_v1',
            signerPublicKey: signerKeyPair.publicKey.toString(),
            targetAddress: signerAddress.toString(),
            scopedMetadataKey,
            // When creating new metadata, valueSizeDelta
            // equals value length
            valueSizeDelta: metadataValue.length,
            value: metadataValue
        });
    console.log('Created embedded metadata transaction:');
    console.log(JSON.stringify(embeddedTransaction.toJson(), null, 2));

    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTransaction];
    const transaction = 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 = new models.Amount(feeMult * transaction.size);

    // Sign and generate final payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);

    // Announce and wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log(
        'Built aggregate transaction with hash:', transactionHash);
    await announceTransaction(jsonPayload, 'aggregate transaction');
    await waitForConfirmation(transactionHash, 'aggregate transaction');

    // --- MODIFYING EXISTING METADATA ---
    console.log('\n--- Modifying existing metadata ---');

    // Fetch current metadata value from network
    const scopedKeyHex = scopedMetadataKey.toString(16)
        .toUpperCase().padStart(16, '0');
    const metadataPath = `/metadata?sourceAddress=${signerAddress}`
        + `&targetAddress=${signerAddress}`
        + `&scopedMetadataKey=${scopedKeyHex}`
        + '&metadataType=0';
    console.log('Fetching current metadata from', metadataPath);
    const metadataResponse = await fetch(`${NODE_URL}${metadataPath}`);
    const metadataJSON = await metadataResponse.json();

    // Get the metadata entry
    if (!metadataJSON.data.length) {
        throw new Error('Metadata entry not found');
    }
    const metadataEntry = metadataJSON.data[0].metadataEntry;
    const currentValue = Buffer.from(metadataEntry.value, 'hex');
    console.log('  Current value:', currentValue.toString('utf8'));

    // XOR the current and new values
    const newValue = new TextEncoder().encode('bob');
    const updateValue = metadataUpdateValue(currentValue, newValue);

    // Create the update transaction with XOR'd value
    const embeddedUpdate = 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 update
    const updateEmbedded = [embeddedUpdate];
    const updateTransaction = 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 = new models.Amount(
        feeMult * updateTransaction.size);

    // Sign and announce the update
    const updateSignature = facade.signTransaction(
        signerKeyPair, updateTransaction);
    const updatePayload = facade.transactionFactory.static
        .attachSignature(updateTransaction, updateSignature);

    // Announce and wait for confirmation
    const updateHash =
        facade.hashTransaction(updateTransaction).toString();
    console.log(
        'Built aggregate transaction with hash:', updateHash);
    await announceTransaction(updatePayload, 'aggregate transaction');
    await waitForConfirmation(updateHash, 'aggregate transaction');

} catch (e) {
    console.error(e.message, '| Cause:', e.cause?.code ?? 'unknown');
}

Download source

Code Explanation⚓︎

This tutorial demonstrates adding new metadata to an account and then updating that metadata.

Setting Up the Account⚓︎

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}')
const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000000');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
const signerAddress = facade.network.publicKeyToAddress(
    signerKeyPair.publicKey);
console.log('Signer address:', signerAddress.toString());

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.

Fetching Network Time and Fees⚓︎

    # Fetch current network time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        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 fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        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}')
    // Fetch current network time
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

Network time and recommended fees are fetched from /node/time GET and /network/fees/transaction GET respectively, following the process described in the Transfer Transaction tutorial.

Defining the Metadata⚓︎

    # Define metadata key and value
    key_string = f'username_{int(time.time())}'
    scoped_metadata_key = metadata_generate_key(key_string)
    metadata_value = 'alice'.encode('utf8')
    // Define metadata key and value
    const keyString = `username_${Date.now()}`;
    const scopedMetadataKey = metadataGenerateKey(keyString);
    const metadataValue = new TextEncoder().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 transaction
    embedded_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 transaction
    const embeddedTransaction = facade.transactionFactory
        .createEmbedded({
            type: 'account_metadata_transaction_v1',
            signerPublicKey: signerKeyPair.publicKey.toString(),
            targetAddress: signerAddress.toString(),
            scopedMetadataKey,
            // When creating new metadata, valueSizeDelta
            // equals value length
            valueSizeDelta: 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).

Building the Aggregate Transaction⚓︎

    # Build the aggregate transaction
    embedded_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 transaction
    const embeddedTransactions = [embeddedTransaction];
    const transaction = 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 = new models.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.

Submitting the Aggregate Transaction⚓︎

    # Sign and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)

    # Announce and wait for confirmation
    transaction_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 payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);

    // Announce and wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log(
        'Built aggregate transaction with hash:', transactionHash);
    await announceTransaction(jsonPayload, 'aggregate transaction');
    await waitForConfirmation(transactionHash, 'aggregate transaction');

The aggregate transaction is signed and announced following the same process as in Creating a Complete Aggregate Transaction.

Retrieving Metadata⚓︎

    # Fetch current metadata value from network
    metadata_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}')
    with urllib.request.urlopen(
            f'{NODE_URL}{metadata_path}') as response:
        response_json = json.loads(response.read().decode())

    # Get the metadata entry
    if not response_json['data']:
        raise Exception('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 network
    const scopedKeyHex = scopedMetadataKey.toString(16)
        .toUpperCase().padStart(16, '0');
    const metadataPath = `/metadata?sourceAddress=${signerAddress}`
        + `&targetAddress=${signerAddress}`
        + `&scopedMetadataKey=${scopedKeyHex}`
        + '&metadataType=0';
    console.log('Fetching current metadata from', metadataPath);
    const metadataResponse = await fetch(`${NODE_URL}${metadataPath}`);
    const metadataJSON = await metadataResponse.json();

    // Get the metadata entry
    if (!metadataJSON.data.length) {
        throw new Error('Metadata entry not found');
    }
    const metadataEntry = metadataJSON.data[0].metadataEntry;
    const currentValue = 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 /metadata GET endpoint with filters for sourceAddress, targetAddress, scopedMetadataKey, and metadataType (0 for account metadata) to retrieve the specific entry.

Modifying Existing Metadata⚓︎

    # XOR the current and new values
    new_value = 'bob'.encode('utf8')
    update_value = metadata_update_value(current_value, new_value)

    # Create the update transaction with XOR'd value
    embedded_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 update
    embedded_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 values
    const newValue = new TextEncoder().encode('bob');
    const updateValue = metadataUpdateValue(currentValue, newValue);

    // Create the update transaction with XOR'd value
    const embeddedUpdate = 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 update
    const updateEmbedded = [embeddedUpdate];
    const updateTransaction = 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 = new models.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 update
    signature = facade.sign_transaction(
        signer_key_pair, update_transaction)
    json_payload = facade.transaction_factory.attach_signature(
        update_transaction, signature)

    # Announce and wait for confirmation
    update_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 update
    const updateSignature = facade.signTransaction(
        signerKeyPair, updateTransaction);
    const updatePayload = facade.transactionFactory.static
        .attachSignature(updateTransaction, updateSignature);

    // Announce and wait for confirmation
    const updateHash =
        facade.hashTransaction(updateTransaction).toString();
    console.log(
        'Built aggregate transaction with hash:', updateHash);
    await announceTransaction(updatePayload, 'aggregate transaction');
    await waitForConfirmation(updateHash, 'aggregate transaction');

The aggregate transaction is then signed and announced as in Creating a Complete Aggregate Transaction.

Output⚓︎

The output shown below corresponds to a typical run of the program.

Using node https://reference.symboltest.net:3001
Signer address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Fetching current network time from /node/time
  Network time: 98406073135 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100

--- Adding new metadata ---
Created embedded metadata transaction:
{
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 1,
  "network": 152,
  "type": 16708,
  "target_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
  "scoped_metadata_key": "16705141506538718608",
  "value_size_delta": 5,
  "value": "616c696365"
}
Built aggregate transaction with hash: 976C555DD4FEEA2B48088457AC445A41FA28E13C88B12823844F5EC399B97144
Announcing aggregate transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for aggregate transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
aggregate transaction confirmed in 11 seconds

--- Modifying existing metadata ---
Fetching current metadata from /metadata?targetAddress=TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I&metadataType=0
  Current value: alice
Created embedded update transaction:
{
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 1,
  "network": 152,
  "type": 16708,
  "target_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
  "scoped_metadata_key": "16705141506538718608",
  "value_size_delta": -2,
  "value": "03030b6365"
}
Built aggregate transaction with hash: AA58533BB20A54FE0CF3AD44EC4CEA05C1BC6A47D817850DBB072D0CB254116F
Announcing aggregate transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for aggregate transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
aggregate transaction confirmed in 31 seconds

Key points in the output:

  • Line 16 ("scoped_metadata_key"): The 64-bit key generated from the input string using SHA3-256 hashing.
  • Line 17 ("value_size_delta": 5): When creating new metadata, this equals the byte length of the value ("alice" = 5 bytes).
  • Line 18 ("value": "616c696365"): The metadata value encoded as hexadecimal ("alice" in UTF-8).
  • Line 20: The transaction hash for looking up the metadata creation in the explorer.
  • Line 31 (Current value: alice): Retrieved from the network before updating.
  • Line 40 ("value_size_delta": -2): Negative because the new value ("bob" = 3 bytes) is shorter than the current value (5 bytes). The difference is -2.
  • Line 41 ("value": "03030b6365"): The XOR'd value computed from the current and new values, not the raw new value.
  • Line 43: The transaction hash for looking up the metadata update in the explorer.

The transaction hashes can be used to search for the transactions in the Symbol Testnet Explorer.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Define metadata key and value
Create an account metadata transaction
Retrieve metadata /metadata GET
Modify existing metadata