How to create a Hedera transaction signed by one account but paid for with another account

203 views Asked by At

I am attempting to replicate the following scenario using the Hedera JavaScript SDK

  • Bob wants to send a transaction to the Hedera network, but he does not have HBAR to cover the transaction fees
  • Alice can offer that service to Bob by sending/executing his transaction on the Hedera network, covering the associated transaction fees

This scenario involves the following steps:

  1. Bob creates the transaction object (e.g. a TokenCreateTransaction)
  2. Bob freezes and signs the transaction
  3. Alice receives the signed transaction from Bob and adds her signature as well
  4. Alice executes the transaction and pays for the transaction fees

You can see below the implementation:

import {
  AccountId,
  TokenCreateTransaction,
  TokenType,
  PrivateKey,
  Client,
} from "@hashgraph/sdk";

//  --- BOB (SIGNER account) ---
const BOB_ACCOUNTID = "0.0.5782085";
const BOB_PRIVATE_KEY = "";

// --- ALICE (PAYER account) ---
const ALICE_ACCOUNTID = "0.0.1079726";
const ALICE_PRIVATE_KEY = "";

async function main() {
  //  --- BOB (SIGNER account) ---
  const bobPrivateKey = PrivateKey.fromStringED25519(BOB_PRIVATE_KEY);
  const bobAccountId = AccountId.fromString(BOB_ACCOUNTID);
  const bobClient = Client.forTestnet().setOperator(
    bobAccountId,
    bobPrivateKey
  );

  // --- ALICE (PAYER account) ---
  const alicePrivateKey = PrivateKey.fromStringED25519(ALICE_PRIVATE_KEY);
  const aliceAccountId = AccountId.fromString(ALICE_ACCOUNTID);
  const aliceClient = Client.forTestnet().setOperator(
    aliceAccountId,
    alicePrivateKey
  );

  // 1. Bob creates the transaction object (e.g. a `TokenCreateTransaction`)
  const transaction = new TokenCreateTransaction()
    .setTokenName("New Token 123")
    .setTokenSymbol("NT123")
    .setTokenType(TokenType.FungibleCommon)
    .setInitialSupply(2000)
    .setTreasuryAccountId(bobAccountId);

  // 2. Bob freezes and signs the transaction
  const frozenTx = await transaction.freezeWith(bobClient);
  const signedTx = await frozenTx.sign(bobPrivateKey);

  // 3. Alice receives the signed transaction from Bob and adds her signature
  const aliceSignedTx = await signedTx.sign(alicePrivateKey);

  // 4. Alice executes the transaction and pays for the transaction fees
  const txResponse = await aliceSignedTx.execute(aliceClient);
  const receipt = await txResponse.getReceipt(aliceClient);
  console.log("TransactionId: " + txResponse.transactionId);
  console.log("Transaction status: " + receipt.status.toString());
  console.log("Created tokenId: " + receipt.tokenId);

  process.exit();
}

main();

The problem arises when Bob freezes the transaction, as certain transaction attributes are automatically modified by the SDK. Specifically, the transactionId and operatorAccountId attributes are set based on the freezer account, therefore designating Bob as the payer of the transaction.

So, even though Alice executes the transaction at the end of the process, the payer account is actually Bob.

You can see here the result where Bob (AccountId 0.0.5782085) is the payer account: https://hashscan.io/testnet/transaction/1698410500.114199003

How can we ensure that Alice is the one who covers the transaction fees, especially when Bob is the initial individual to freeze it? Any idea would be greatly appreciated.

2

There are 2 answers

0
Diego Escalona On BEST ANSWER

After multiple attempts, I've discovered a solution to the problem.

When Bob is creating the transaction, he needs to set the transactionId using the setTransactionId() method as shown below:

const transaction = new TokenCreateTransaction()
  .setTransactionId(transactionId)

The transactionId parameter is crucial in this process. Bob must create it using Alice's AccountId to designate Alice as the payer of the transaction. To generate it, Bob can utilize the generate() method from the TransactionId class. Make sure that the class is imported at the beginning of the code:

import { TransactionId } from "@hashgraph/sdk";

const transactionId = TransactionId.generate(aliceAccountId)

In this way, we can ensure that Bob initiates, freezes and sings the transaction while designating Alice as the payer account for covering the transaction fees.

Important: this flow requires that Bob knows Alice's AccountId before creating the transaction.

Here is all the updated code (only 2 lines are added, marked with a comment as: <--- New Line):

import {
  AccountId,
  TokenCreateTransaction,
  TokenType,
  PrivateKey,
  Client,
  TransactionId, // <--- New Line
} from "@hashgraph/sdk";

//  --- BOB Account (SIGNER) ---
const BOB_ACCOUNTID = "0.0.5782085";
const BOB_PRIVATE_KEY = "";

// --- ALICE Account (PAYER) ---
const ALICE_ACCOUNTID = "0.0.1079726";
const ALICE_PRIVATE_KEY = "";

async function main() {
  //  --- BOB (SIGNER account) ---
  const bobPrivateKey = PrivateKey.fromStringED25519(BOB_PRIVATE_KEY);
  const bobAccountId = AccountId.fromString(BOB_ACCOUNTID);
  const bobClient = Client.forTestnet().setOperator(
    bobAccountId,
    bobPrivateKey
  );

  // --- ALICE (PAYER account) ---
  const alicePrivateKey = PrivateKey.fromStringED25519(ALICE_PRIVATE_KEY);
  const aliceAccountId = AccountId.fromString(ALICE_ACCOUNTID);
  const aliceClient = Client.forTestnet().setOperator(
    aliceAccountId,
    alicePrivateKey
  );

  // 1. Bob creates the transaction object (e.g. a `TokenCreateTransaction`) 
  // and sets a specific transactionId to designate Alice as the payer account 
  const transaction = new TokenCreateTransaction()
    .setTokenName("New Token 123")
    .setTokenSymbol("NT123")
    .setTokenType(TokenType.FungibleCommon)
    .setInitialSupply(2000)
    .setTreasuryAccountId(bobAccountId)
    .setTransactionId(TransactionId.generate(aliceAccountId)); // <--- New Line

  // 2. Bob freezes and signs the transaction
  const frozenTx = await transaction.freezeWith(bobClient);
  const signedTx = await frozenTx.sign(bobPrivateKey);

  // 3. Alice receives the signed transaction from Bob and adds her signature
  const doubleSignedTx = await signedTx.sign(alicePrivateKey);

  // 4. Alice executes the transaction and pays for the transaction fees
  const txResponse = await doubleSignedTx.execute(aliceClient);
  const receipt = await txResponse.getReceipt(aliceClient);
  console.log("TransactionId: " + txResponse.transactionId);
  console.log("Transaction status: " + receipt.status.toString());
  console.log("Created tokenId: " + receipt.tokenId);

  process.exit();
}

main();

You can see here the result where Alice (AccountId 0.0.1079726) is the payer account: https://hashscan.io/testnet/transaction/1698423106.148047003

Hope it will be helpful to someone!

0
bwonymph On

Freezing the transaction in preparation to be signed requires the transaction ID (which denotes payer) to be set. This is usually by default set to the account that freezes the transaction. Hence, it isn't possible to change the payer once the transaction is frozen. So if not the freezing account, it'll need to designated manually upfront during freezing.

This is shown in Diego's answer.