Skip to content

Paying Transaction Fees on Behalf of Another Account⚓︎

Using a blockchain typically requires users to own the network's native currency in order to pay transaction fees. For newcomers, this often means acquiring funds through an exchange and completing KYC procedures.

Fortunately, apps can offer a more streamlined experience by taking care of transaction fees on behalf of their users.

On Ethereum, this functionality was initially introduced through standards such as EIP-4337, which enables account abstraction and sponsored transactions. Further proposals, including EIP-7702, build on this approach by allowing regular accounts to adopt smart contract behavior.

On Symbol, similar behavior can be implemented out of the box using aggregate transactions. This tutorial presents two techniques that allow a Symbol account to pay transaction fees on behalf of another account.

Succinct example

For simplicity, this tutorial only shows the code that builds the transactions.

As a result, the code shown is not directly executable. Refer to the Transfer Transaction tutorial to learn how to announce and confirm the transactions constructed in this tutorial.

Problem Statement⚓︎

Consider a messaging application in which users control their own Symbol accounts and exchange messages by sending transfer transactions with a message payload. The application relays messages and triggers notifications when new ones are received. No central authority can censor or block communication.

Messages can also be encrypted so that only the intended recipient can read them, but encryption is outside the scope of this tutorial.

FeeSponsorshipProblemclusterTransferMessage TransactionAAccount ABAccount BA->B🖂

To improve usability, the application developer chooses to pay transaction fees on behalf of users, while still sending messages from each user's own account. The cost of these fees can then be recovered through external billing mechanisms, such as a monthly subscription paid in traditional currency, for example.

The trade-off for this convenience is the introduction of a centralization point limited to transaction fee payment, which users can remove individually once they choose to manage fees themselves.

Option 1: Prefunded Fees⚓︎

In this approach, an account controlled by the application developer sends a prefund transfer to the user's account.

To ensure that the funds cannot misused, both the prefund transfer and the message transfer are embedded in a single aggregate transaction. The aggregate is signed by both the application account and the user, and announced by the latter.

Transaction fees are deducted after all embedded transactions are executed, which makes the prefunded amount available to the message sender for paying all transaction fees.

Note that the prefund amount must be sufficient to cover both embedded transactions' fees, and that the order of the embedded transactions does not matter in this case.

Option1clusterAggregateAggregate TransactionclusterT1Embedded Message TransactionclusterT2Embedded Prefund TransactionA1Account AB1Account BA1->B1🖂C2App AccountA2Account AC2->A2💲

For security reasons, the application should not hold any private keys belonging to the application account. Instead, it can build a complete aggregate transaction and request the developer's signature through an off-chain API, or build a bonded aggregate transaction and request the signature exclusively through on-chain means.

Once the developer's signature is obtained, the application can attach the user's signature and announce the aggregate transaction, even if the user's account balance is zero.

def build_prefunded_message_transaction(recipient_address, message):
    # Build the embedded message transaction
    message_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account sending the message
        'signer_public_key': user_key_pair.public_key,
        'recipient_address': recipient_address,
        'message': message
    })

    # Build the embedded prefund transaction
    prefund_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account funding the transaction fee
        'signer_public_key': app_key_pair.public_key,
        # Account receiving the funds
        'recipient_address':
            facade.network.public_key_to_address(
                user_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 0 # To be filled once value is known
        }]
    })

    # Build the wrapper complete aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': user_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions': [message_transaction, prefund_transaction]
    })
    # Calculate total fee, reserving space for a cosignature
    transaction.fee = sc.Amount(fee_mult * (transaction.size + 104))
    # Update the prefund amount to match the total fee
    prefund_transaction.mosaics[0].amount = transaction.fee
    # Update the embedded transaction hashes
    transaction.transactions_hash = sc.Hash256(
        facade.hash_embedded_transactions(
            [message_transaction, prefund_transaction]).bytes)

    # Sign the aggregate transaction using the user's signature
    facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(user_key_pair, transaction))
    # Attach the app's cosignature
    transaction.cosignatures.append(
        facade.cosign_transaction(app_key_pair, transaction))
    # Obtain the payload
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(user_key_pair, transaction))

    return (transaction, json_payload)
function buildPrefundedMessageTransaction(recipientAddress, message) {
    // Build the embedded message transaction
    const messageTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account sending the message
        signerPublicKey: userKeyPair.publicKey,
        recipientAddress,
        message
    });

    // Build the embedded prefund transaction
    const prefundTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account funding the transaction fee
        signerPublicKey: appKeyPair.publicKey,
        // Account receiving the funds
        recipientAddress: facade.network.publicKeyToAddress(
            userKeyPair.publicKey
        ),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 0 // To be filled once value is known
        }]
    });

    // Build the wrapper complete aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: userKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactions: [messageTransaction, prefundTransaction]
    });
    // Calculate total fee, reserving space for a cosignature
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );
    // Update the prefund amount to match the total fee
    prefundTransaction.mosaics[0].amount = transaction.fee;
    // Update the embedded transaction hashes
    transaction.transactionsHash = new models.Hash256(
        facade.static.hashEmbeddedTransactions(
            [messageTransaction, prefundTransaction]
        ).bytes
    );

    // Sign the aggregate transaction using the user's signature
    facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(userKeyPair, transaction)
    );
    // Attach the app's cosignature
    transaction.cosignatures.push(
        facade.cosignTransaction(appKeyPair, transaction)
    );
    // Obtain the payload
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(userKeyPair, transaction)
    );

    return { transaction, jsonPayload };
}

Message Transaction⚓︎

The embedded transaction that sends the message from the user to the recipient is a standard transfer transaction.

    # Build the embedded message transaction
    message_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account sending the message
        'signer_public_key': user_key_pair.public_key,
        'recipient_address': recipient_address,
        'message': message
    })
    // Build the embedded message transaction
    const messageTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account sending the message
        signerPublicKey: userKeyPair.publicKey,
        recipientAddress,
        message
    });

Prefund Transaction⚓︎

This is also a transfer transaction, with the particularity that the amount to transfer is not yet known. The total fee depends on the final size of the aggregate transaction, which cannot be calculated at this stage. For this reason, the amount is initially set to 0.

The sender of this transaction is the application account, and the recipient is the user account.

    # Build the embedded prefund transaction
    prefund_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account funding the transaction fee
        'signer_public_key': app_key_pair.public_key,
        # Account receiving the funds
        'recipient_address':
            facade.network.public_key_to_address(
                user_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 0 # To be filled once value is known
        }]
    })
    // Build the embedded prefund transaction
    const prefundTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account funding the transaction fee
        signerPublicKey: appKeyPair.publicKey,
        // Account receiving the funds
        recipientAddress: facade.network.publicKeyToAddress(
            userKeyPair.publicKey
        ),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 0 // To be filled once value is known
        }]
    });

Aggregate Transaction⚓︎

The complete aggregate transaction is built as usual, and its fee field is updated once the transaction size is known.

The prefund transaction's amount is then set to match the calculated fee.

Finally, the transactions_hash field is updated with the hash of the embedded transactions. This field is normally set when the aggregate is created using , but in this case it must be updated afterwards, once the prefund transaction has been modified.

Caution

As shown in the code, when setting the transactions_hash field, use the model-specific type sc.Hash256 () or models.Hash256 (), and not the generic cryptography type Hash256.

    # Build the wrapper complete aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': user_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions': [message_transaction, prefund_transaction]
    })
    # Calculate total fee, reserving space for a cosignature
    transaction.fee = sc.Amount(fee_mult * (transaction.size + 104))
    # Update the prefund amount to match the total fee
    prefund_transaction.mosaics[0].amount = transaction.fee
    # Update the embedded transaction hashes
    transaction.transactions_hash = sc.Hash256(
        facade.hash_embedded_transactions(
            [message_transaction, prefund_transaction]).bytes)
    // Build the wrapper complete aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: userKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactions: [messageTransaction, prefundTransaction]
    });
    // Calculate total fee, reserving space for a cosignature
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );
    // Update the prefund amount to match the total fee
    prefundTransaction.mosaics[0].amount = transaction.fee;
    // Update the embedded transaction hashes
    transaction.transactionsHash = new models.Hash256(
        facade.static.hashEmbeddedTransactions(
            [messageTransaction, prefundTransaction]
        ).bytes
    );

Signatures⚓︎

The user account adds its signature using , since it is the signer of the aggregate transaction. The application account's cosignature is then added using , which can be obtained through either on-chain or off-chain methods, as explained above.

    # Sign the aggregate transaction using the user's signature
    facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(user_key_pair, transaction))
    # Attach the app's cosignature
    transaction.cosignatures.append(
        facade.cosign_transaction(app_key_pair, transaction))
    # Obtain the payload
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(user_key_pair, transaction))
    // Sign the aggregate transaction using the user's signature
    facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(userKeyPair, transaction)
    );
    // Attach the app's cosignature
    transaction.cosignatures.push(
        facade.cosignTransaction(appKeyPair, transaction)
    );
    // Obtain the payload
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(userKeyPair, transaction)
    );

Once the payload is obtained, the transaction is ready to be announced and confirmed.

Option 2: Sponsored Fees⚓︎

In an aggregate transaction, all transaction fees are paid exclusively by the aggregate signer. A straightforward solution to the stated problem is therefore to embed the message transaction in an aggregate transaction signed by the application account.

However, Symbol requires every aggregate signer to participate in at least one embedded transaction. As a result, an additional filler embedded transaction must be included that requires the application account's signature.

This filler transaction must have no side effects and an empty transfer from the application account to itself is a suitable choice.

Option2clusterAggregateAggregate TransactionclusterT1Embedded Message TransactionclusterT2Embedded Filler TransactionA1Account AB1Account BA1->B1🖂C2App AccountA2App AccountC2->A20

Compared to Option 1, this approach is simpler to set up, as it does not require calculating the total fee after the embedded transactions are built, nor updating the transactions and their hashes. On the other hand, the application account takes a more active role, which might be counterproductive if the goal is to empower users to eventually manage their own funds and fees.

Option 2 is also slightly cheaper, since the filler transaction is smaller than the prefunding transfer.

def build_sponsored_message_transaction(recipient_address, message):
    # Build the embedded message transaction
    message_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account sending the message
        'signer_public_key': user_key_pair.public_key,
        'recipient_address': recipient_address,
        'message': message
    })

    # Build the embedded filler transaction
    filler_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # The application account is both the sender and the recipient
        # and there is no `mosaics` field
        'signer_public_key': app_key_pair.public_key,
        'recipient_address':
            facade.network.public_key_to_address(
                app_key_pair.public_key)
    })

    # Build the wrapper complete aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': app_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            [message_transaction, filler_transaction]),
        'transactions': [message_transaction, filler_transaction]
    })
    # Calculate total fee, reserving space for a cosignature
    transaction.fee = sc.Amount(fee_mult * (transaction.size + 104))

    # Sign the aggregate transaction using the app's signature
    facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(app_key_pair, transaction))
    # Attach the users's cosignature
    transaction.cosignatures.append(
        facade.cosign_transaction(user_key_pair, transaction))
    # Obtain the payload
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(app_key_pair, transaction))

    return (transaction, json_payload)
function buildSponsoredMessageTransaction(recipientAddress, message) {
    // Build the embedded message transaction
    const messageTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account sending the message
        signerPublicKey: userKeyPair.publicKey,
        recipientAddress,
        message
    });

    // Build the embedded filler transaction
    const fillerTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // The application account is both the sender and the recipient
        // and there is no `mosaics` field
        signerPublicKey: appKeyPair.publicKey,
        recipientAddress: facade.network.publicKeyToAddress(
            appKeyPair.publicKey
        )
    });

    // Build the wrapper complete aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: appKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            [messageTransaction, fillerTransaction]),
        transactions: [messageTransaction, fillerTransaction]
    });
    // Calculate total fee, reserving space for a cosignature
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );

    // Sign the aggregate transaction using the app's signature
    facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(appKeyPair, transaction)
    );
    // Attach the user's cosignature
    transaction.cosignatures.push(
        facade.cosignTransaction(userKeyPair, transaction)
    );
    // Obtain the payload
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(appKeyPair, transaction)
    );

    return { transaction, jsonPayload };
}

Message Transaction⚓︎

The embedded transaction that sends the message from the user to the recipient is a standard transfer transaction.

    # Build the embedded message transaction
    message_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # Account sending the message
        'signer_public_key': user_key_pair.public_key,
        'recipient_address': recipient_address,
        'message': message
    })
    // Build the embedded message transaction
    const messageTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // Account sending the message
        signerPublicKey: userKeyPair.publicKey,
        recipientAddress,
        message
    });

Filler Transaction⚓︎

This is also a transfer transaction, from the application account to itself, in which no funds are transferred. As explained above, its only purpose is to allow the application account to sign and pay for the aggregate transaction.

    # Build the embedded filler transaction
    filler_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        # The application account is both the sender and the recipient
        # and there is no `mosaics` field
        'signer_public_key': app_key_pair.public_key,
        'recipient_address':
            facade.network.public_key_to_address(
                app_key_pair.public_key)
    })
    // Build the embedded filler transaction
    const fillerTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        // The application account is both the sender and the recipient
        // and there is no `mosaics` field
        signerPublicKey: appKeyPair.publicKey,
        recipientAddress: facade.network.publicKeyToAddress(
            appKeyPair.publicKey
        )
    });

Aggregate Transaction⚓︎

The complete aggregate transaction is built as usual, updating its fee field once the transaction size is known. But unlike Option 1, it does not need to be further modified.

    # Build the wrapper complete aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': app_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            [message_transaction, filler_transaction]),
        'transactions': [message_transaction, filler_transaction]
    })
    # Calculate total fee, reserving space for a cosignature
    transaction.fee = sc.Amount(fee_mult * (transaction.size + 104))
    // Build the wrapper complete aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: appKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            [messageTransaction, fillerTransaction]),
        transactions: [messageTransaction, fillerTransaction]
    });
    // Calculate total fee, reserving space for a cosignature
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );

Signatures⚓︎

The application account adds its signature using , since it is the signer of the aggregate transaction. This signature can be obtained through either on-chain or off-chain methods, as explained above.

The user account's cosignature is then added using .

    # Sign the aggregate transaction using the app's signature
    facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(app_key_pair, transaction))
    # Attach the users's cosignature
    transaction.cosignatures.append(
        facade.cosign_transaction(user_key_pair, transaction))
    # Obtain the payload
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(app_key_pair, transaction))
    // Sign the aggregate transaction using the app's signature
    facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(appKeyPair, transaction)
    );
    // Attach the user's cosignature
    transaction.cosignatures.push(
        facade.cosignTransaction(userKeyPair, transaction)
    );
    // Obtain the payload
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(appKeyPair, transaction)
    );

Once the payload is obtained, the transaction is ready to be announced and confirmed.

Conclusion⚓︎

This tutorial demonstrated two ways to have one account pay transaction fees on behalf of another using aggregate transactions. Both approaches allow applications to sponsor transaction fees while preserving user ownership and control over their accounts.

By separating fee payment from transaction authorization, applications can provide a smoother onboarding experience for new users without sacrificing the decentralization properties of the system.