Bridge over-balance input sends the full balance while the UI shows a different amount

Severity: Medium
Component: ghost-dao-interface / src/containers/Bridge/BridgeCard.jsx
Commit tested: a911859

Summary

When the bridge amount is larger than the user’s GHST balance, BridgeCard.jsx clamps the value to the user’s full balance.

The problem is that the UI does not use that same value consistently.

The bridge transaction uses the clamped preparedAmount, while the "You will get" amount is calculated from the amount typed by the user.

For example, with a balance of 10 GHST and an input of 100 GHST:

Input: 100 GHST
Button disabled: false
"You will get": 99 GHST
Transaction amount: 10 GHST

The amount shown by the UI therefore does not match the amount passed to the bridge transaction.

Root cause

In src/containers/Bridge/BridgeCard.jsx:

const preparedAmount = useMemo(() => {
  try {
    const result = BigInt(parseFloat(amount) * Math.pow(10, 18));

    if (result > ghstBalance._value) {
      return ghstBalance._value;
    }

    return result;
  } catch {
    return 0n;
  }
}, [amount]);

An amount greater than the user’s balance is replaced with the entire balance.

That value is then passed to the bridge call:

const txHash = await ghost(
  chainId,
  address,
  convertedReceiver,
  preparedAmount
);

However, amountAfterFee is calculated from the typed input rather than the clamped amount.

The balance check also compares the balance against preparedAmount. After the clamp, the value is equal to the balance, so an over-balance input is not rejected by that check.

Proof of Concept

I reproduced the component logic in a Node.js driver using the same BigInt wei representation. The wallet broadcast was captured instead of being sent.

Wallet balance: 10 GHST

Test 1
Typed amount: 100 GHST
Button disabled: false
"You will get": 99 GHST
Transaction sends: 10 GHST

Test 2
Typed amount: 1000 GHST
Button disabled: false
"You will get": 990 GHST
Transaction sends: 10 GHST

Control
Typed amount: 5 GHST
Button disabled: false
"You will get": 4.95 GHST
Transaction sends: 5 GHST

The behavior reproduced consistently across repeated runs.

Impact

An extra zero in the bridge amount is enough to cause the transaction to use the user’s entire GHST balance.

The main issue is the mismatch between the amount presented by the interface and the amount actually passed to the transaction. The user can see a result calculated from the amount they typed while the transaction uses a different amount.

Expected behavior

An amount greater than the available balance should be rejected, or the interface should update all displayed amounts to the exact value that will be submitted.

The amount shown in the confirmation flow should always correspond to the amount passed to the bridge transaction.

Suggested fix

The simplest fix is to remove the silent clamp and let the balance check reject over-balance input:

const preparedAmount = useMemo(() => {
  try {
    return BigInt(parseFloat(amount) * Math.pow(10, 18));
  } catch {
    return 0n;
  }
}, [amount]);

Then the existing balance check can operate on the actual requested amount.

At minimum, if the clamp is intentional, the confirmation UI and fee calculation should use the same clamped amount that is actually sent.

Thank you for your report!

You are right - the values are desynced between what the UI shows and what is actually calculated. I don’t think we should let the user type any value they want and then execute a different one; that would be worse. So I believe your second option is much better.

You can continue this thread - could you provide a more in-depth fix for synchronizing the UI and the calculated value?

Thanks. I agree that the clamped amount should be the single source of truth for the rest of the bridge flow.

I would structure it so the typed value is parsed once, then normalized against the wallet balance. Everything shown to the user and everything sent on-chain should use that same normalized value.

For example:

const requestedAmount = parseAmount(amount);

const effectiveAmount =
  requestedAmount > ghstBalance._value
    ? ghstBalance._value
    : requestedAmount;

Then effectiveAmount should be used for the fee/result calculation:

const amountAfterFee = calculateAmountAfterFee(effectiveAmount);

and for the actual transaction:

const txHash = await ghost(
  chainId,
  address,
  convertedReceiver,
  effectiveAmount
);

The confirmation UI should also display effectiveAmount, not the raw typed amount.

For example, with a 10 GHST balance and the user typing 100 GHST:

Entered:       100 GHST
Amount sent:    10 GHST
You will get:   9.90 GHST

I would also keep an explicit over-balance state so the user is told that the entered amount exceeded the balance and was capped. That way the clamp is visible instead of silently changing the transaction amount.

There is another point I would change at the same time: the amount should not be converted with parseFloat(amount) * 1e18 for the value used by the transaction. Parsing the decimal input exactly avoids cases where the displayed amount and the actual integer amount differ because of floating-point rounding.

The invariant I would enforce is:

displayed amount == amount passed to ghost()

and the fee / “You will get” calculation should be derived from that same value.