> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metalntwx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Estimate and record fees

> Gas tokens and transaction fees on Metal

> [!NOTE]
>
> **What you'll be able to do**
>
> * Estimate a transfer's transaction fee.
> * Check that the signing account can cover the transfer and maximum gas cost.
> * Verify the final fee from the transaction receipt.

Metal charges a transaction fee based on the gas consumed and the effective
gas price. On testnet, the default gas token is `*USDZ`.

The bank or PSP sets its customer-facing service charge separately. It can
use the transaction fee as an input to that pricing.

## Choose the gas token

Transaction fees are deducted directly from the signing account's balance in
the gas token. No separate token-spending approval is required. Keep the
transaction's native `value` at zero.

| Transaction format | Token that pays for gas |
| - | - |
| Standard Ethereum transaction | The chain's default gas token |
| Metal `0x7f` transaction | Token specified in `gasToken` |

On testnet, the default gas token is `*USDZ` at
`0x000000000000000000000000000000000000B5D5`, with 18 decimals. The
[Metal SDK](https://docs.metalntwx.com/reference/typescript-sdk.md) supports explicit selection through its
`gasToken` argument. Only tokens recognised by the MIP20 allowlist
can pay gas.

> [!NOTE]
>
> RPC credit in the console pays for endpoint requests. Transaction fees are
> paid from the signing account's balance in the gas token.

Gas prices are integer amounts in the gas token's smallest units. There
is no automatic currency conversion. For example, a fee of `120000` base units
is `0.12` of a six-decimal token but `0.00000000000012` of an 18-decimal token.
Read the gas token's `decimals()` when displaying costs.

## Calculate the quote and balance requirement

The gas limit caps the work a transaction may consume. The gas price is the
token amount paid per unit of work.

Metal's execution base fee is zero. For a transaction with fee-cap fields:

```text theme={null}
effective gas price = min(maxFeePerGas, maxPriorityFeePerGas)
estimated fee = estimated gas × effective gas price
maximum gas cost = gas limit × maxFeePerGas
final fee = receipt.gasUsed × receipt.effectiveGasPrice
```

Read the gas price using `eth_gasPrice` (`client.getGasPrice()` in viem).
Metal requires a positive effective price, a priority fee no greater than the
fee cap, and a fee cap no greater than `1_000_000_000_000` base units per gas.
The SDK sets both fields from its `maxFeePerGas` argument.

For a direct transfer paid in the same token, require:

```text theme={null}
sender balance ≥ transfer amount + maximum gas cost
```

If the token being transferred differs from the gas token, check each balance separately.
Also account for any other token spending performed by the contract call.

A higher gas limit increases the balance required upfront. Unused execution
gas is refunded, so the final charge depends on the gas actually consumed.

These calculations cover transactions without blobs.
[Blob transactions](https://docs.metalntwx.com/reference/network-fundamentals/blobs.md) have a separate blob fee.

## Add quoting and fee accounting to an application

Use a dedicated test account, following the
[quickstart account instructions](https://docs.metalntwx.com/start/make-your-first-transfer.md#step-1-create-a-test-account)
if you need to create one. Fund it with `*USDZ` through the
[faucet](https://docs.metalntwx.com/build/prepare/fund-a-test-account.md) and supply its key as `PRIVATE_KEY` through
secret configuration. For an application with a custody or signing service,
provide that signer through the SDK's wallet client.

Set `RECIPIENT_ADDRESS` to a different EVM address you control. The test transfer
sends `0.01` test tokens and compares the resulting account balances, so
both accounts must have no other activity during the test.

From the application directory, install the public packages:

```bash theme={null}
pnpm add @metalntwx/metal-ts@0.3.2 viem@2
```

## Configure the test signer

Create a wallet client for the signer using the application's `rpcUrl`.
The transaction transfers `*USDZ` and pays gas in `*USDZ`.

Reading balances and estimating fees require only the sender's public address.
The private key is needed to sign the transaction.

```typescript theme={null}
import { createWalletClient, getAddress, http, parseUnits, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const key = process.env.PRIVATE_KEY;
const recipientInput = process.env.RECIPIENT_ADDRESS;
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key) || !recipientInput) {
  throw new Error("Set PRIVATE_KEY and RECIPIENT_ADDRESS.");
}
const account = privateKeyToAccount(key as Hex);
const wallet = createWalletClient({ account, transport: http(rpcUrl) });
const sender = account.address;
const recipient = getAddress(recipientInput);
if (recipient === sender) throw new Error("Use a different recipient.");
const amount = parseUnits("0.01", 18); // Testnet *USDZ has 18 decimals.
```

For application-supplied amounts, follow the precision checks in
[Direct payment](https://docs.metalntwx.com/build/payments/direct-payment.md#convert-a-payment-amount).

## Estimate the transfer

```typescript theme={null}
import { encodeFunctionData, erc20Abi } from "viem";
import { signMip20Contract } from "@metalntwx/metal-ts";

const token = "0x000000000000000000000000000000000000b5d5" as const;
const price = await client.getGasPrice();
if (price < 1n || price > 1_000_000_000_000n) throw new Error("Invalid gas price.");
const data = encodeFunctionData({
  abi: erc20Abi, functionName: "transfer", args: [recipient, amount],
});
const estimatedGas = await client.estimateGas({
  account: sender, to: token, data, value: 0n,
});
const gasLimit = estimatedGas + estimatedGas / 5n + 10_000n;
const estimatedFee = estimatedGas * price;
const maximumFee = gasLimit * price;
```

Omit fee fields from the estimate. A nonzero effective gas price can make the
inherited Ethereum estimator return `gas required exceeds allowance (0)`, even
when the signing account's balance in the gas token is sufficient. If estimation
fails, stop before signing and investigate the cause. Add 20% plus 10,000 gas
to the estimate as headroom, as shown above.

## Check affordability

Before preparing the transaction, read the sender's spendable balance and
check that it covers the transfer and maximum gas cost. Record the recipient's
balance at the same block to reconcile both changes later:

```typescript theme={null}
const beforeBlock = await client.getBlockNumber({ cacheTime: 0 });
const balanceAt = (address: `0x${string}`, blockNumber: bigint) =>
  client.readContract({
    address: token, abi: erc20Abi, functionName: "balanceOf",
    args: [address], blockNumber,
  });
const balance = await balanceAt(sender, beforeBlock);
const recipientBefore = await balanceAt(recipient, beforeBlock);
if (balance < amount + maximumFee) throw new Error("Insufficient amount plus gas.");
```

When using different transfer and gas tokens, require the transfer amount in
the first and `maximumFee` in the second. Keep these calculations in integer
base units. Read the gas token's decimals and use `formatUnits` for display.

## Submit with the quoted gas budget

The wallet client must use `sender` as its signing account. Pass the same token,
amount, price, and limit used in the quote:

```typescript theme={null}
const signed = await signMip20Contract({
  walletClient: wallet, publicClient: client,
  address: token, abi: erc20Abi, functionName: "transfer",
  args: [recipient, amount], gasToken: token,
  maxFeePerGas: price, gasLimit, gasFallback: gasLimit,
});
```

Supplying `gasLimit` makes the SDK use the quoted limit. The SDK still requires
`gasFallback`, but does not use it on this path.

Save `signed.txHash` and `signed.raw` in the transaction record
before calling `await signed.send()`. Submit transactions sequentially for each
signing account.

## Record the actual fee

After submission, look up the saved transaction hash:

```typescript theme={null}
const receipt = await client.waitForTransactionReceipt({
  hash: signed.txHash, timeout: 60_000,
});
const actualFee = receipt.gasUsed * receipt.effectiveGasPrice;
```

Record the transaction hash, gas-token address, receipt status, and `actualFee`
in base units. Calculate and record the fee even when execution reverted.

Treat a successful transaction as a completed payment only after
[payment verification](https://docs.metalntwx.com/build/payments/verify-and-reconcile-payments.md) and
[finality checks](https://docs.metalntwx.com/reference/network-fundamentals/settlement.md) pass. If receipt polling times out,
keep looking up the saved hash until the outcome is known.

For this direct transfer between otherwise inactive accounts, verify the fee
against balances at the receipt block:

```typescript theme={null}
if (receipt.status !== "success") throw new Error("Transfer reverted; record its fee.");
const senderAfter = await balanceAt(sender, receipt.blockNumber);
const recipientAfter = await balanceAt(recipient, receipt.blockNumber);
if (actualFee > maximumFee || balance - senderAfter !== amount + actualFee
  || recipientAfter - recipientBefore !== amount) {
  throw new Error("Fee or balance mismatch; inspect account activity and the receipt.");
}
```

For these inactive accounts, the balance changes should show that the sender
paid the amount plus the transaction fee and the recipient received the amount.
Concurrent activity, yield accounting or quarantine can change those differences.
Use transaction events to identify individual payments. If a successful transfer
credits [ReceivePolicy quarantine](https://docs.metalntwx.com/reference/compliance/receive-policy.md), record its fee and
quarantine receipt separately. Do not count the intended recipient as paid or
submit a replacement payment automatically.

## Handle failures and uncertain submissions

| Result | Fee accounting and recovery |
| - | - |
| Local validation or simulation fails before submission | No onchain transaction was sent. Correct the inputs, funding, or receive policy and quote again. |
| Node explicitly rejects admission | That rejected submission incurs no transaction fee. Diagnose the error before retrying and check any earlier submission of the same transaction. |
| Included transaction has a reverted receipt | The transfer's state changes are rolled back, but `gasUsed × effectiveGasPrice` is still charged. Record the fee and investigate the revert. |
| Timeout or lost RPC response | The transaction may have been accepted. Look up the saved hash. Do not create a replacement payment merely because the request timed out. |

Exercise the application's checks with missing configuration, the wrong
network, and an amount larger than the funded balance. Each should stop before
submission. Keep receipt status separate from fee calculation as a failure can
still incur a cost.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.