javascriptsolidityhedera-hashgraph

How to get updated remaining HTS fungible token allowance on Hedera?


I have a DApp with a contract that has an approved allowance to spend HTS fungible tokens on behalf of a user. However, the contract keeps reverting with SPENDER_DOES_NOT_HAVE_ALLOWANCE error.

I have a condition in my code to ask the DApp’s user to approve another allowance transaction if the current allowance is less than the required amount.

if (amountGranted < tokenAmountToSpend) {
    // perform allowance tx 
}

This seems like it should be sufficient. However, when I query the following endpoint of the mirror node:

/api/v1/account/{accountId}/allowances/tokens

the response’ allowances.amount_granted value shows the original token allowance granted. But it does not show the updated token allowance. Is there a different endpoint I can call to get the updated (remaining) token allowance?


Steps to reproduce:

  1. Create a smart contract with a function that transfers tokens from the caller to the contract (itself).
  2. Deploy the smart contract on Hedera.
  3. Create a new Hedera account and create FT’s or use an existing account that already owns FTs. Ref: Hedera Account
  4. Associate the contract with the Hedera accounts FTs you plan to send to it.
  5. Use the JS SDK to grant an allowance to the contract of with a token amount of 3.
  6. Query the mirror node for allowances info from: https://testnet.mirrornode.hedera.com/api/v1/accounts/${ownerAccountID}/allowances/tokens and add a condition that checks if amountGranted < tokenAmountToSpend … if it is, call grantAllowance().
  7. Invoke the smart contract function transfer where the Hedera account is the caller and the transfer amount is 2.
  8. This call succeeds.
  9. Invoke the smart contract function transfer where the Hedera account is the caller and the transfer amount is 2.
  10. This call does not succeed: Contract Revert with SPENDER_DOES_NOT_HAVE_ALLOWANCE.

Smart Contract:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TransferCallerTokens {
  function transfer(address token, uint256 amount) public {
    IERC20(token).transferFrom(msg.sender, address(this), amount);
  }
}

Smart contract deployment:

async function deployContract() {
  const bytecode = fs.readFileSync(
    "TransferCallerTokens_sol_TransferCallerTokens.bin");

    // Switch operators (for the client) before executing the transaction,
  // as you need to sign using the contract's admin key
  client.setOperator(adminAccountId, adminKey);

  // Build the transaction
  const contractCreate = new ContractCreateFlow()
    .setGas(100_000)
    .setBytecode(bytecode)
    .setAdminKey(adminKey)
  const contractCreateTxResponse = await contractCreate.execute(client);
  const contractCreateReceipt = await contractCreateTxResponse.getReceipt(client);
  const transferCallerTokensContractID = contractCreateReceipt.contractId;

  console.log(`The transferCallerTokensContractID: ${transferCallerTokensContractID}`);
}

Using the SDK to create my fungible token. Note: The FT belongs to the user, and not the smart contract above.

// Create a HTS Fungible Token
async function createFungibleToken(
    client,
  treasuryAccountId,
  treasuryAccountPrivateKey,
) {
  // Generate supply key
  const supplyKeyForFT = PrivateKey.generateED25519();

  // Confgiure the token
  const createFTokenTxn = await new TokenCreateTransaction()
    .setTokenName('LoeweToken')
    .setTokenSymbol('LO')
    .setTokenType(TokenType.FungibleCommon)
    .setDecimals(1)
    .setInitialSupply(100)
    .setTreasuryAccountId(treasuryAccountId)
    .setSupplyKey(supplyKeyForFT)
    .setMaxTransactionFee(new Hbar(30))
    .freezeWith(client);

  // Sign the transaction with the treasury account private key
  const createFTokenTxnSigned = await
        createFTokenTxn.sign(treasuryAccountPrivateKey);
  const createFTokenTxnResponse = await
        createFTokenTxnSigned.execute(client);
}

Granting an allowance

// Grant allowance to smart contract
async function grantAllowance() {
    const tokenAmount = 2;
    const approveAllowanceTx = new AccountAllowanceApproveTransaction()
    .approveTokenAllowance(
            fungibleTokenId, ownerAccountId, spenderAccountId, tokenAmount
        )
    .freezeWith(client);

    const approveAllowanceTxSign = await approveAllowanceTx
        .sign(
            PrivateKey.fromString(process.env.MY_PRIVATE_KEY)
        );

  const approveAllowanceTxResponse = await approveAllowanceTxSign.execute(client);
  await approveAllowanceTxResponse.getReceipt(client);
}

Execute the Contract function transfer

// execute transfer of HTS fungible token
async function executeTransferTransaction() {
  const amount = 2;

    const allowanceInfo = await getAllowance();
  const contractAllowanceInfo = allowanceInfo.allowances.find(
        (x) => (x.spender === '0.0.15079297' && x.token_id === '0.0.14073131')
    );
  const contractAmountGranted = contractAllowanceInfo.amount_granted
  console.log(`Amount Granted for Contract is: ${contractAmountGranted}`)
  
    if (contractAmountGranted < amount) {
    await grantAllowance();
  }

  const transferFromTx = new ContractExecuteTransaction()
    .setContractId(contractID)
    .setFunction(
            'transfer',
            new ContractFunctionParameters()
          .addAddress(tokenIDSolidityAddress)
          .addUint256(amount),
        )
    .setGas(3_000_000)
    .freezeWith(client);

  const transferFromTxResponse = await transferFromTx.execute(client);
  const receipt = await transferFromTxResponse.getReceipt(client);
  console.log(`Execute Transfer on TransferCallerTokens status ${receipt.status}`);
}

Solution

  • When calling Mirror Node endpoint /api/v1/account/{accountId}/allowances/tokens the response allowance.amount_granted does not return the remaining allowance.

    The engineering team is aware of and is working on closing that gap. In the meantime, the following workarounds are recommended:

    Approach 1: Perform an allowance transaction every time the contract needs to spend on behalf of the dApp user.

    This will request the dApp user an approval before executing the smart contract function.

    //Create the transaction
    const transaction = new AccountAllowanceApproveTransaction()
        .approveTokenAllowance(tokenId, ownerAccount, spenderAccountId, tokenAmount);
    

    The pro of approach 1 is you will not have contract reverts due to the fact the allowance has already been spent but the mirror node does not return the updated allowance.

    The con is that it is possible that the user does have a sufficient remaining allowance but they are paying for network fee to complete an approval they didn’t need.

    Approach 2: Execute a contract function directly to system contract for allowances which will return the the remaining number of tokens that spender will be allowed to spend on behalf of owner

    If you are using Hedera wallets use ContractExecuteTransaction to call allowance directly on the FT token.

    /*
     * Use the SDK to execute a contract function directly to system contract 
     * to get the remaining number of tokens that spender will be allowed to spend on behalf of owner:
    */
      const allowanceTransaction = new ContractExecuteTransaction()
        .setContractId(fungibleTokenId)
        .setFunction('allowance', new ContractFunctionParameters()
          .addAddress(ownerAccountIdSolidityAddress)
          .addAddress(spenderAccountIdSolidityAddress))
        .setGas(30_000)
        .freezeWith(client);
    

    If you are using using EVM wallets use eth_call to execute the contract function allowance directly on FT token

    async getAllowance(tokenSolidityAddress) {
        const provider = new ethers.providers.Web3Provider(window.ethereum);
        const signer = await provider.getSigner();
        // create contract instance for the contract id (token id)
        const contract = new ethers.Contract(tokenSolidityAddress, [`function allowance()`], signer);
        try {
          const txResult = await contract.allowance(ownerAddress, spenderAddress);
          return txResult.hash;
        } catch (error: any) {
          console.warn(error.message ? error.message : error);
          return null;
        }
      }
    

    The con about the second approach is that a user may have to pay twice and sign two transactions:

    1. to execute a contract function to check if there is a sufficient allowance
    2. to execute a contract function to give an allowance