typescriptsdkhedera-hashgraph

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


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

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.


Solution

  • 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!