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.
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.
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.
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.
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.
defbuild_prefunded_message_transaction(recipient_address,message):# Build the embedded message transactionmessage_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 transactionprefund_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 transactiontransaction=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 cosignaturetransaction.fee=sc.Amount(fee_mult*(transaction.size+104))# Update the prefund amount to match the total feeprefund_transaction.mosaics[0].amount=transaction.fee# Update the embedded transaction hashestransaction.transactions_hash=sc.Hash256(facade.hash_embedded_transactions([message_transaction,prefund_transaction]).bytes)# Sign the aggregate transaction using the user's signaturefacade.transaction_factory.attach_signature(transaction,facade.sign_transaction(user_key_pair,transaction))# Attach the app's cosignaturetransaction.cosignatures.append(facade.cosign_transaction(app_key_pair,transaction))# Obtain the payloadjson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(user_key_pair,transaction))return(transaction,json_payload)
functionbuildPrefundedMessageTransaction(recipientAddress,message){// Build the embedded message transactionconstmessageTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account sending the messagesignerPublicKey:userKeyPair.publicKey,recipientAddress,message});// Build the embedded prefund transactionconstprefundTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account funding the transaction feesignerPublicKey:appKeyPair.publicKey,// Account receiving the fundsrecipientAddress:facade.network.publicKeyToAddress(userKeyPair.publicKey),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:0// To be filled once value is known}]});// Build the wrapper complete aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:userKeyPair.publicKey,deadline:timestamp.addHours(2).timestamp,transactions:[messageTransaction,prefundTransaction]});// Calculate total fee, reserving space for a cosignaturetransaction.fee=newmodels.Amount(feeMult*(transaction.size+104));// Update the prefund amount to match the total feeprefundTransaction.mosaics[0].amount=transaction.fee;// Update the embedded transaction hashestransaction.transactionsHash=newmodels.Hash256(facade.static.hashEmbeddedTransactions([messageTransaction,prefundTransaction]).bytes);// Sign the aggregate transaction using the user's signaturefacade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(userKeyPair,transaction));// Attach the app's cosignaturetransaction.cosignatures.push(facade.cosignTransaction(appKeyPair,transaction));// Obtain the payloadconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(userKeyPair,transaction));return{transaction,jsonPayload};}
# Build the embedded message transactionmessage_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 transactionconstmessageTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account sending the messagesignerPublicKey:userKeyPair.publicKey,recipientAddress,message});
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 transactionprefund_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 transactionconstprefundTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account funding the transaction feesignerPublicKey:appKeyPair.publicKey,// Account receiving the fundsrecipientAddress:facade.network.publicKeyToAddress(userKeyPair.publicKey),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:0// To be filled once value 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 transactiontransaction=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 cosignaturetransaction.fee=sc.Amount(fee_mult*(transaction.size+104))# Update the prefund amount to match the total feeprefund_transaction.mosaics[0].amount=transaction.fee# Update the embedded transaction hashestransaction.transactions_hash=sc.Hash256(facade.hash_embedded_transactions([message_transaction,prefund_transaction]).bytes)
// Build the wrapper complete aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:userKeyPair.publicKey,deadline:timestamp.addHours(2).timestamp,transactions:[messageTransaction,prefundTransaction]});// Calculate total fee, reserving space for a cosignaturetransaction.fee=newmodels.Amount(feeMult*(transaction.size+104));// Update the prefund amount to match the total feeprefundTransaction.mosaics[0].amount=transaction.fee;// Update the embedded transaction hashestransaction.transactionsHash=newmodels.Hash256(facade.static.hashEmbeddedTransactions([messageTransaction,prefundTransaction]).bytes);
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 signaturefacade.transaction_factory.attach_signature(transaction,facade.sign_transaction(user_key_pair,transaction))# Attach the app's cosignaturetransaction.cosignatures.append(facade.cosign_transaction(app_key_pair,transaction))# Obtain the payloadjson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(user_key_pair,transaction))
// Sign the aggregate transaction using the user's signaturefacade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(userKeyPair,transaction));// Attach the app's cosignaturetransaction.cosignatures.push(facade.cosignTransaction(appKeyPair,transaction));// Obtain the payloadconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(userKeyPair,transaction));
Once the payload is obtained, the transaction is ready to be announced and confirmed.
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.
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.
defbuild_sponsored_message_transaction(recipient_address,message):# Build the embedded message transactionmessage_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 transactionfiller_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 transactiontransaction=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 cosignaturetransaction.fee=sc.Amount(fee_mult*(transaction.size+104))# Sign the aggregate transaction using the app's signaturefacade.transaction_factory.attach_signature(transaction,facade.sign_transaction(app_key_pair,transaction))# Attach the users's cosignaturetransaction.cosignatures.append(facade.cosign_transaction(user_key_pair,transaction))# Obtain the payloadjson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(app_key_pair,transaction))return(transaction,json_payload)
functionbuildSponsoredMessageTransaction(recipientAddress,message){// Build the embedded message transactionconstmessageTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account sending the messagesignerPublicKey:userKeyPair.publicKey,recipientAddress,message});// Build the embedded filler transactionconstfillerTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// The application account is both the sender and the recipient// and there is no `mosaics` fieldsignerPublicKey:appKeyPair.publicKey,recipientAddress:facade.network.publicKeyToAddress(appKeyPair.publicKey)});// Build the wrapper complete aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:appKeyPair.publicKey,deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions([messageTransaction,fillerTransaction]),transactions:[messageTransaction,fillerTransaction]});// Calculate total fee, reserving space for a cosignaturetransaction.fee=newmodels.Amount(feeMult*(transaction.size+104));// Sign the aggregate transaction using the app's signaturefacade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(appKeyPair,transaction));// Attach the user's cosignaturetransaction.cosignatures.push(facade.cosignTransaction(userKeyPair,transaction));// Obtain the payloadconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(appKeyPair,transaction));return{transaction,jsonPayload};}
# Build the embedded message transactionmessage_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 transactionconstmessageTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// Account sending the messagesignerPublicKey:userKeyPair.publicKey,recipientAddress,message});
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 transactionfiller_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 transactionconstfillerTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',// The application account is both the sender and the recipient// and there is no `mosaics` fieldsignerPublicKey:appKeyPair.publicKey,recipientAddress:facade.network.publicKeyToAddress(appKeyPair.publicKey)});
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 transactiontransaction=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 cosignaturetransaction.fee=sc.Amount(fee_mult*(transaction.size+104))
// Build the wrapper complete aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:appKeyPair.publicKey,deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions([messageTransaction,fillerTransaction]),transactions:[messageTransaction,fillerTransaction]});// Calculate total fee, reserving space for a cosignaturetransaction.fee=newmodels.Amount(feeMult*(transaction.size+104));
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 signaturefacade.transaction_factory.attach_signature(transaction,facade.sign_transaction(app_key_pair,transaction))# Attach the users's cosignaturetransaction.cosignatures.append(facade.cosign_transaction(user_key_pair,transaction))# Obtain the payloadjson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(app_key_pair,transaction))
// Sign the aggregate transaction using the app's signaturefacade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(appKeyPair,transaction));// Attach the user's cosignaturetransaction.cosignatures.push(facade.cosignTransaction(userKeyPair,transaction));// Obtain the payloadconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(appKeyPair,transaction));
Once the payload is obtained, the transaction is ready to be announced and confirmed.
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.