Most fee calculators are a percentage input with a currency symbol. That breaks when a route has several payouts, the user switches networks, or the destination accepts a different USDT representation.
The multiplication is easy. The engineering problem is deciding which costs belong in the model, which assumptions must stay visible, and when the calculator should refuse to display a number.
Treating the cost as amount - gas works only in the simplest case. It assumes one transfer, no provider fee, the correct token contract, and a destination that supports the selected network. Add a percentage charge or multiple payouts and the shortcut starts returning confident nonsense.
The better model separates four things:
- the USDT amount;
- any percentage-based service charge;
- a network-cost allowance for each payout;
- whether the destination supports the exact chain and token representation.
There is a fifth value worth showing even though it may not reduce the current USDT balance: the native gas asset needed for the next transaction.
This article builds that model in TypeScript without using floating-point arithmetic.
Start with the accounting, not the UI
For a route that charges a percentage and creates one or more payouts, the planning formula is:
service fee = amount * service rate
network allowance = allowance per payout * payout count
estimated output = amount - service fee - network allowance
For a direct wallet transfer, set the service rate to zero and the payout count to one. For a bridge, exchange withdrawal, payment processor, or another service-mediated route, the other fields may apply.
The word allowance matters. A network fee may be paid in TRX, ETH, BNB, SOL, TON, or POL rather than in USDT. Converting it to a USDT-equivalent value can make a comparison easier, but that converted amount is a planning input. It is not automatically the amount that will be deducted from the token balance.
The final provider or wallet quote remains the source of truth.
Seven rails, six native gas assets
The ticker USDT does not identify a route. A useful first pass needs at least the network and the native asset used for gas or resources.
| Route label | Network | Native gas or resource asset |
|---|---|---|
| TRC20 | Tron | TRX |
| ERC20 | Ethereum | ETH |
| BEP20 | BNB Smart Chain | BNB |
| SPL | Solana | SOL |
| Jetton | TON | TON |
| Arbitrum One | Arbitrum | ETH |
| Polygon PoS | Polygon | POL |
That table is still not enough for production. Key an asset by chain identifier plus token contract, mint, or master address. Do not trust the ticker alone.
This is especially important on EVM networks. Ethereum, BNB Smart Chain, Arbitrum, and Polygon can all use a 0x address. A matching address shape does not mean the receiving exchange or wallet accepts USDT on every one of those chains.
Use a maintained chain SDK for address syntax instead of inventing one regular expression. Even then, treat syntax validation and destination support as separate checks.
We can still use a compact route type for the estimator:
type Rail =
| "trc20"
| "erc20"
| "bep20"
| "solana"
| "ton"
| "arbitrum"
| "polygon";
type RouteMeta = {
label: string;
nativeGasAsset: "TRX" | "ETH" | "BNB" | "SOL" | "TON" | "POL";
};
const ROUTES: Record<Rail, RouteMeta> = {
trc20: { label: "Tron / TRC20", nativeGasAsset: "TRX" },
erc20: { label: "Ethereum / ERC20", nativeGasAsset: "ETH" },
bep20: { label: "BNB Smart Chain / BEP20", nativeGasAsset: "BNB" },
solana: { label: "Solana / SPL", nativeGasAsset: "SOL" },
ton: { label: "TON / Jetton", nativeGasAsset: "TON" },
arbitrum: { label: "Arbitrum One", nativeGasAsset: "ETH" },
polygon: { label: "Polygon PoS", nativeGasAsset: "POL" },
};
In a real application, I would extend RouteMeta with the chain ID and token identifier. I would also store decimals, the data source, and the time at which the allowance was updated.
Avoid binary floating point for token amounts
JavaScript's number type is convenient, but money math eventually finds its edge cases. The expression 0.1 + 0.2 is the familiar example. Fee calculations add multiplication and rounding, so small errors can become visible at the exact point where users expect exact output.
For this model, values are stored as millionths of a USDT in bigint. The parser rejects more than six decimal places instead of silently rounding the input.
const USDT_SCALE = 1_000_000n;
const BPS_SCALE = 10_000n;
function parseUsdt(input: string): bigint {
const match = /^(0|[1-9]\d*)(?:\.(\d{1,6}))?$/.exec(input.trim());
if (!match) {
throw new Error("Expected a non-negative USDT amount with up to 6 decimals");
}
const whole = BigInt(match[1]);
const fraction = BigInt((match[2] ?? "").padEnd(6, "0"));
return whole * USDT_SCALE + fraction;
}
function formatUsdt(value: bigint): string {
const sign = value < 0n ? "-" : "";
const absolute = value < 0n ? -value : value;
const whole = absolute / USDT_SCALE;
const fraction = (absolute % USDT_SCALE).toString().padStart(6, "0");
return `${sign}${whole}.${fraction}`;
}
function divideRoundUp(numerator: bigint, denominator: bigint): bigint {
if (numerator < 0n || denominator <= 0n) {
throw new Error("divideRoundUp expects a non-negative numerator");
}
return (numerator + denominator - 1n) / denominator;
}
The rate is stored in basis points:
1 basis point = 0.01%
5 basis points = 0.05%
20 basis points = 0.20%
Rounding the percentage fee up to the smallest supported unit makes the estimate conservative. If the actual provider uses a different rounding policy, mirror that policy and cover it with tests.
This example uses six decimal places for USDT. Production code should verify the decimals of the exact contract, mint, or Jetton master rather than accepting an arbitrary token with the same ticker. bigint also cannot be serialized directly by JSON.stringify(), so API boundaries should transport token amounts as validated decimal strings.
Build rejection into the estimator
A calculator should refuse an invalid route before it displays a neat total.
The input below includes the destination's supported rails, the minimum amount, and a network allowance supplied by a separate fee source or configuration snapshot.
type EstimateInput = {
amountMicroUsdt: bigint;
serviceRateBps: bigint;
payoutCount: number;
maximumPayouts: number;
rail: Rail;
destinationRails: ReadonlySet<Rail>;
minimumMicroUsdt: bigint;
networkAllowancePerPayoutMicroUsdt: bigint;
};
type Estimate = {
rail: Rail;
serviceFeeMicroUsdt: bigint;
networkAllowanceMicroUsdt: bigint;
estimatedOutputMicroUsdt: bigint;
nativeGasAssetForNextMove: RouteMeta["nativeGasAsset"];
};
function estimateRoute(input: EstimateInput): Estimate {
if (!Number.isSafeInteger(input.maximumPayouts) || input.maximumPayouts < 1) {
throw new Error("maximumPayouts must be a positive integer");
}
if (
!Number.isSafeInteger(input.payoutCount) ||
input.payoutCount < 1 ||
input.payoutCount > input.maximumPayouts
) {
throw new Error(`payoutCount must be between 1 and ${input.maximumPayouts}`);
}
if (input.amountMicroUsdt < 0n) {
throw new Error("amount must not be negative");
}
if (input.serviceRateBps < 0n || input.serviceRateBps > BPS_SCALE) {
throw new Error("serviceRateBps must be between 0 and 10,000");
}
if (input.networkAllowancePerPayoutMicroUsdt < 0n) {
throw new Error("network allowance must not be negative");
}
if (!input.destinationRails.has(input.rail)) {
throw new Error(`destination does not support ${ROUTES[input.rail].label}`);
}
if (input.amountMicroUsdt < input.minimumMicroUsdt) {
throw new Error(
`amount is below the ${formatUsdt(input.minimumMicroUsdt)} USDT minimum`,
);
}
const serviceFeeMicroUsdt = divideRoundUp(
input.amountMicroUsdt * input.serviceRateBps,
BPS_SCALE,
);
const networkAllowanceMicroUsdt =
input.networkAllowancePerPayoutMicroUsdt * BigInt(input.payoutCount);
const estimatedOutputMicroUsdt =
input.amountMicroUsdt - serviceFeeMicroUsdt - networkAllowanceMicroUsdt;
if (estimatedOutputMicroUsdt <= 0n) {
throw new Error("fees and allowances consume the full amount");
}
return {
rail: input.rail,
serviceFeeMicroUsdt,
networkAllowanceMicroUsdt,
estimatedOutputMicroUsdt,
nativeGasAssetForNextMove: ROUTES[input.rail].nativeGasAsset,
};
}
The destination check belongs before the arithmetic. A perfectly calculated ERC20 estimate is useless when the recipient accepts only TRC20.
Run an example
The following values are deliberately illustrative. They are not current network quotes:
- amount: 1,000 USDT;
- service rate: 20 basis points, or 0.20%;
- planning allowance: 1 USDT per payout;
- payout count: 2;
- selected rail: TRC20.
const quote = estimateRoute({
amountMicroUsdt: parseUsdt("1000"),
serviceRateBps: 20n,
payoutCount: 2,
maximumPayouts: 3,
rail: "trc20",
destinationRails: new Set<Rail>(["trc20", "erc20"]),
minimumMicroUsdt: parseUsdt("50"),
networkAllowancePerPayoutMicroUsdt: parseUsdt("1"),
});
console.table({
serviceFee: `${formatUsdt(quote.serviceFeeMicroUsdt)} USDT`,
networkAllowance: `${formatUsdt(quote.networkAllowanceMicroUsdt)} USDT`,
estimatedOutput: `${formatUsdt(quote.estimatedOutputMicroUsdt)} USDT`,
nextMoveGasAsset: quote.nativeGasAssetForNextMove,
});
The result is:
service fee: 2.000000 USDT
network allowance: 2.000000 USDT
estimated output: 996.000000 USDT
next move gas: TRX
Changing the payout count from one to two increases the network allowance but not the percentage fee. That distinction is easy to lose when the UI exposes only a single field called "fee."
Keep the network allowance outside the formula code
The estimator should be deterministic. Fetching live gas data inside estimateRoute() would mix networking, caching, conversion, and arithmetic in one function.
Keep those concerns separate:
type AllowanceSnapshot = {
rail: Rail;
microUsdtPerPayout: bigint;
source: string;
updatedAt: string;
};
An adapter can produce the snapshot from a provider quote, a wallet estimate, or a chain-specific gas calculation. The estimator consumes the snapshot and returns the same result for the same input.
The UI should display updatedAt. When the snapshot is too old for the application's policy, refresh it or stop presenting the result as current. Do not hide stale data behind extra decimal places.
Show the native gas requirement separately
Suppose a fresh wallet receives USDT on Solana. The wallet can hold the token, but its next token transfer may require SOL. The same operational issue exists with TRX on Tron, ETH on Ethereum or Arbitrum, BNB on BNB Smart Chain, TON on TON, and POL on Polygon.
That requirement should be visible next to the estimate:
Estimated USDT received: 996.000000
Native asset needed for the next move: TRX
Do not quietly subtract a guessed native-asset top-up from the output. Only deduct it when the route actually performs and charges for that top-up. Otherwise, it is a separate requirement with a separate source.
Tests worth keeping
The happy path is the least interesting test. I would keep fixtures for these cases:
- zero service rate for a direct transfer;
- one payout versus several payouts;
- an amount exactly at the minimum;
- an amount one micro-USDT below the minimum;
- an unsupported destination rail;
- a stale or missing allowance snapshot;
- a percentage fee that requires rounding;
- fees that would consume the entire amount;
- EVM routes with the same address text but different chain IDs;
- a correct network paired with the wrong token contract or mint.
Address syntax tests should use maintained network libraries. A regex can check a shape, but it cannot prove ownership, destination support, or the intended chain.
Property-based tests are useful here. For every valid input, the output should never exceed the input, no fee should be negative, and adding a payout should never increase the estimated output.
What the calculator cannot decide
This model can make cost accounting explicit. It cannot decide whether a route is legal in a jurisdiction or whether a provider's server-side claims are accurate. It also cannot predict whether a wallet or exchange will accept the funds later.
It also cannot turn a planning allowance into a guaranteed quote. Network conditions, service minimums, conversion rates, receiver confirmation policies, and the final provider screen can all change the result.
An interactive version of this planning model shows the percentage charge, per-payout allowance, and estimated output as separate rows. It labels the result as an estimate rather than a live quote.
Disclosure: CleanUSDT hosts the linked calculator and may have commercial relationships with third-party routes. The link is included so readers can inspect the behavior described here; it is not an independent recommendation or a live provider quote. The code and examples cover estimation mechanics only. They are not financial, legal, sanctions, or compliance advice.
The safest estimator is willing to reject a route. A number built on the wrong chain is not an estimate. It is a bug with currency formatting.
Top comments (0)