Frontend & SDK Integration
Fee Profiling and Estimation

Fee Profiling and Estimation

Fee profiling is how a GenLayer app turns contract tests into reusable fee suggestions. The output is a JSON file, usually frontend/fee-profile.json or fee-profile.json, that any submission path can use when it estimates a deploy or write transaction.

The profile is not a live simulation result. It is a checked-in artifact generated from representative tests. Prices and caps are still read live at transaction time.

@genlayer/transaction-kit can consume the profile directly for frontend flows. If you are building a CLI, backend worker, Python script or custom JS app without transaction-kit, use the same profile values as inputs to estimateTransactionFees / estimate_transaction_fees, then submit the returned distribution and feeValue.

Why profiles exist

GenLayer transactions can take different paths:

  • a method may or may not emit messages
  • a method may make different LLM or web calls depending on state
  • a child transaction may terminate early or run through an expensive branch
  • appeals and rotations change the amount of consensus work that must be funded
  • storage, receipt and rollup writes vary with output size

Simulating all of that before every user action would be too slow. Instead, the developer measures the important branches during tests, commits the maximum observed requirements and lets the app quote from that profile.

How methods are aggregated

Profiles are keyed by contract method name, not by pytest test name and not by arguments.

If five tests call register_and_claim, all five observations are merged into one methods.register_and_claim entry. For each field, the profiler keeps the maximum value observed across the whole run, then applies headroom to fee and time-unit values.

Example:

Test pathExecution budgetMessage feesRotations
user already registered, no referral message60000
new user, emits referral message2008001

The generated profile uses:

{
  "methods": {
    "register_and_claim": {
      "executionBudgetPerRound": "600",
      "totalMessageFees": "800",
      "rotationsPerRound": "1"
    }
  }
}

That combined entry may not describe a single historical transaction. It is intentionally conservative: it is the union of the worst observed components for that method. This is the right default for app safety because a user can usually trigger any branch that the method allows.

If your app can prove a specific user flow cannot hit the expensive branch, use an explicit override for that flow. Keep the committed profile conservative.

What to measure

Measure scenarios, not just methods. A good profile suite covers:

  • deploy, if the app lets users deploy contracts
  • the cheapest successful path
  • the most expensive normal path
  • every branch that emits a different message set
  • every branch that changes storage or receipt size materially
  • every branch where child transactions can run different paths
  • expected failure/revert paths, if users can trigger them and they consume fees

For a Rally-style contract, that means tests for paths such as:

  • user already registered, no referral message
  • user not registered, referral message emitted
  • main transaction passes and emits the shard message
  • main transaction fails before the shard message
  • shard transaction terminates early
  • shard transaction reaches the expensive branch

The parent method profile should include the worst observed totalMessageFees across these paths. If the parent emits child transactions, the message fee budget must be large enough for the worst child path that the parent can create.

Generate the profile

Create a script in the app repository so developers do not have to remember the exact command:

{
  "scripts": {
    "test:fees": "python3 -m pytest tests/integration/test_football_bets.py::test_fee_profile_create_bet --fee-profile frontend/fee-profile.json -v -s"
  }
}

Run it against a fee-reporting Studio or network:

npm run test:fees -- --rpc-url http://127.0.0.1:4000/api

The underlying mechanism is a pytest option provided by gltest:

python3 -m pytest tests/integration --fee-profile frontend/fee-profile.json

Use --fee-profile-headroom to adjust the multiplier:

python3 -m pytest tests/integration \
  --fee-profile frontend/fee-profile.json \
  --fee-profile-headroom 1.5

The default headroom is 1.25.

Wait for finalized receipts

Profile tests should wait for finalized receipts:

receipt = contract.create_bet(args=["2024-06-20", "Spain", "Italy", "1"]).transact(
    fees=transaction_fee_preset(),
    wait_until="finalized",
)

Earlier statuses can prove that validators accepted a transaction before all fee accounting and refunds are settled. Finalized receipts are the reliable source for the profile.

Submit the profiling transaction

The measured transaction itself still needs a fee preset. In local Studio runs, use a trusted developer preset sized from the active fee policy:

from gltest.clients import get_gl_client
 
FEE_ESTIMATE_OPTIONS = {
    "leaderTimeunitsAllocation": 100,
    "validatorTimeunitsAllocation": 200,
    "totalMessageFees": 0,
    "rotations": [1],
}
 
def transaction_fee_preset():
    estimate = get_gl_client().estimate_transaction_fees(FEE_ESTIMATE_OPTIONS)
    return {
        "distribution": estimate["distribution"],
        "feeValue": estimate["feeValue"],
    }

Use that preset on deploys and writes:

contract = factory.deploy(fees=transaction_fee_preset(), wait_until="finalized")
 
receipt = contract.create_bet(args=["2024-06-20", "Spain", "Italy", "1"]).transact(
    fees=transaction_fee_preset(),
    wait_until="finalized",
)

For message-producing scenarios, increase totalMessageFees in the profiling preset so the emitted child transactions have enough budget. The generated profile records the observed message budget from the finalized receipt. If no message-producing path is measured, that method will recommend a zero message budget.

Profile shape

A generated profile looks like this:

{
  "version": 1,
  "network": "localnet",
  "measuredAt": "2026-06-15T17:26:36Z",
  "deploy": {
    "leaderTimeunitsAllocation": "125",
    "validatorTimeunitsAllocation": "250",
    "executionBudgetPerRound": "786434",
    "totalMessageFees": "0",
    "rotationsPerRound": "1"
  },
  "methods": {
    "create_bet": {
      "leaderTimeunitsAllocation": "125",
      "validatorTimeunitsAllocation": "250",
      "executionBudgetPerRound": "786297",
      "totalMessageFees": "0",
      "rotationsPerRound": "1"
    }
  }
}
FieldMeaning
leaderTimeunitsAllocationSuggested leader time units for the method or deploy.
validatorTimeunitsAllocationSuggested validator time units for the method or deploy.
executionBudgetPerRoundBudget for receipt, storage and rollup writes per leader round.
totalMessageFeesBudget for child transactions emitted by the contract.
rotationsPerRoundSuggested rotations per leader round. This is recorded exactly, not multiplied by headroom.

The profile intentionally does not contain feeValue or live price caps. Those are quoted from the current network fee policy when the user is about to sign.

Use the profile in transaction-kit

For frontend flows, commit the profile next to the frontend and pass it to the kit:

import feeProfile from './fee-profile.json';
 
const kit = createTransactionKit({
  chain,
  provider,
  account,
  suggestions: feeProfile,
});

When a transaction matches a profile entry, the quote source is developer. Methods without a matching entry fall back to network defaults.

Use the profile with genlayer-js

Without transaction-kit, load the profile yourself, select the deploy or method entry, convert rotationsPerRound into the rotations array for the appeal posture you want, then estimate from live prices:

import feeProfile from './fee-profile.json';
 
const methodProfile = feeProfile.methods.create_bet;
const appealRounds = 1n;
const rotationsPerRound = BigInt(methodProfile.rotationsPerRound ?? '0');
 
const estimate = await client.estimateTransactionFees({
  leaderTimeunitsAllocation: BigInt(methodProfile.leaderTimeunitsAllocation),
  validatorTimeunitsAllocation: BigInt(methodProfile.validatorTimeunitsAllocation),
  executionBudgetPerRound: BigInt(methodProfile.executionBudgetPerRound),
  totalMessageFees: BigInt(methodProfile.totalMessageFees ?? '0'),
  appealRounds,
  rotations: Array.from(
    { length: Number(appealRounds) + 1 },
    () => rotationsPerRound,
  ),
});
 
await client.writeContract({
  address: contract,
  functionName: 'create_bet',
  args: ['2024-06-20', 'Spain', 'Italy', '1'],
  fees: {
    distribution: estimate.distribution,
    feeValue: estimate.feeValue,
  },
});

Use the profile with genlayer-py

Python follows the same shape:

import json
 
with open("fee-profile.json", encoding="utf-8") as f:
    fee_profile = json.load(f)
 
method_profile = fee_profile["methods"]["create_bet"]
appeal_rounds = 1
rotations_per_round = int(method_profile.get("rotationsPerRound", "0"))
 
estimate = client.estimate_transaction_fees(
    {
        "leaderTimeunitsAllocation": int(method_profile["leaderTimeunitsAllocation"]),
        "validatorTimeunitsAllocation": int(method_profile["validatorTimeunitsAllocation"]),
        "executionBudgetPerRound": int(method_profile["executionBudgetPerRound"]),
        "totalMessageFees": int(method_profile.get("totalMessageFees", "0")),
        "appealRounds": appeal_rounds,
        "rotations": [rotations_per_round] * (appeal_rounds + 1),
    }
)
 
tx_hash = client.write_contract(
    account=account,
    address=contract_address,
    function_name="create_bet",
    args=["2024-06-20", "Spain", "Italy", "1"],
    fees={
        "distribution": estimate["distribution"],
        "feeValue": estimate["feeValue"],
    },
)

Use the profile with the CLI

The CLI can consume the generated profile directly:

genlayer estimate-fees 0x123456789abcdef create_bet \
  --fee-profile frontend/fee-profile.json \
  --fee-preset standard \
  --json \
  --args "2024-06-20" "Spain" "Italy" "1"

For writes, pass the same profile path when submitting:

genlayer write 0x123456789abcdef create_bet \
  --fee-profile frontend/fee-profile.json \
  --fee-preset standard \
  --args "2024-06-20" "Spain" "Italy" "1"

For deploys, the CLI uses the profile's deploy entry:

genlayer deploy \
  --contract contracts/football_bets.py \
  --fee-profile frontend/fee-profile.json

write and targeted estimate-fees use methods[method]. The CLI converts the measured profile entry into SDK estimate options, reads current prices from the backend, then submits the returned distribution and feeValue.

--fee-preset controls the appeal posture. The standard preset maps to one appeal round; low maps to zero and high maps to two. Use --appeal-rounds for an explicit override.

Use --fees alongside --fee-profile only when a flow needs to override part of the generated profile, for example a custom messageAllocations set. Use --fee-value only when you need to force the transaction deposit instead of using the SDK estimate.

This is intentionally the same policy flow as transaction-kit: the profile provides allocation suggestions, while the SDK/CLI estimate reads current network prices and produces the final deposit.

Regenerate policy

Regenerate and review fee-profile.json when you change:

  • contract code that affects LLM calls, web requests, messages, storage or receipt data
  • application arguments that select different branches
  • child transaction behavior
  • intended appeal or rotation posture
  • the GenVM or Studio version used for fee accounting

Treat the file like a performance budget. It should change in the same pull request as the behavior that changed the fee shape.