Skip to content

Payout API accepts partially numeric amount strings and silently changes the requested value #41

Description

@mssystem1

Summary

The payout endpoint parses user-supplied monetary amounts with parseFloat() and then converts the resulting JavaScript number into USDC atomic units using floating-point multiplication and Math.floor().

parseFloat() accepts a valid numeric prefix and ignores trailing invalid characters.

For example:

parseFloat("10USDC") // 10
parseFloat("1.5abc") // 1.5

As a result, malformed input can pass validation and trigger a real payout for a value different from the literal request body.

The conversion also silently truncates values with more than six decimal places.

Affected file

app/api/payout/route.ts

Amount conversion:

if (isNaN(val)) return "0";
return BigInt(Math.floor(val * 1_000_000)).toString();
}
async function getCircleWalletAddress(walletId: string): Promise<Address> {

Amount validation and use:

const body = await req.json();
const {
recipientAddress,
amount,
destinationChain: requestedChain,
sourceType = "auto",
sourceWalletId
} = body;
if (!recipientAddress || !amount) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// A non-USDC payout is only satisfiable on a true same-chain wallet source
// (the `else` branch below, which calls sendUsdcOnSameChainWithAppKit
// directly). "auto" picks its wallet by USDC balance, and every
// cross-chain/"gateway" path settles through Gateway's burn/mint, which is
// USDC-only.
//
// Reject rather than silently downgrade: this endpoint moves money, so a
// request asking for EURC must never quietly send USDC instead.
const rawCurrency = body.currency ?? "USDC";
if (!(CURRENCIES as readonly string[]).includes(rawCurrency)) {
return NextResponse.json(
{
error: "Unsupported currency",
userMessage: `Currency must be one of: ${CURRENCIES.join(", ")}.`,
},
{ status: 400 }
);
}
const requestedCurrency = rawCurrency as Currency;
if (requestedCurrency !== "USDC" && sourceType !== "wallet") {
return NextResponse.json(
{
error: "Unsupported currency for this source",
userMessage: `${requestedCurrency} payouts require an explicit same-chain wallet source. Gateway and automatic routing settle in USDC only.`,
},
{ status: 400 }
);
}
const amountNum = parseFloat(amount);
if (isNaN(amountNum) || amountNum <= 0) {
return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
}
const amountInAtomicUnits = BigInt(convertToSmallestUnit(amount));
const destinationChain: SupportedChain = requestedChain || "arcTestnet";
// Fetch user's wallets

Severity

Medium

Steps to reproduce

  1. Authenticate as a user with a funded wallet.

  2. Submit a payout request with a partially numeric amount:

    curl -X POST https://example.com/api/payout \
      -H "Content-Type: application/json" \
      -H "Cookie: <authenticated-session>" \
      -d '{
        "recipientAddress": "0xRECIPIENT",
        "amount": "1USDC",
        "destinationChain": "arcTestnet",
        "sourceType": "auto"
      }'
  3. Observe the parsing behavior:

    parseFloat("1USDC") === 1
  4. The request passes the current validation because the parsed result is a positive number.

  5. The endpoint may proceed with a payout of 1 USDC, even though "1USDC" is not a valid amount representation.

A second example demonstrates silent precision truncation:

{
  "amount": "0.0000009"
}

The validation accepts this because it is greater than zero, but the current conversion produces:

Math.floor(0.0000009 * 1_000_000) === 0

The requested positive amount is therefore converted to zero atomic units.

Expected behavior

The endpoint should accept only a complete decimal amount string matching the supported USDC precision.

Examples of valid input:

1
1.25
0.000001

Examples that should be rejected:

1USDC
1.2abc
0.0000009
1,25
Infinity
1e3

The exact accepted syntax may differ, but malformed or over-precision values must not be silently reinterpreted.

Actual behavior

The endpoint:

  1. uses parseFloat(), which accepts partially numeric strings;
  2. validates only the parsed numeric prefix;
  3. multiplies a JavaScript floating-point number by 1_000_000;
  4. applies Math.floor(), silently truncating excess precision;
  5. constructs the payout amount from the altered value.

Impact

This may cause:

  • malformed requests to trigger real payouts;
  • mismatch between the submitted amount and transferred amount;
  • silent truncation of user amounts;
  • zero-value transaction attempts;
  • inaccurate transaction records;
  • inconsistent balance validation;
  • difficult reconciliation between API requests and on-chain transfers.

Financial APIs should not silently normalize or partially parse invalid monetary input.

Root cause

The endpoint uses permissive floating-point parsing for fixed-precision token values:

const val = parseFloat(amount);
return BigInt(Math.floor(val * 1_000_000)).toString();

parseFloat() is not a strict decimal validator, and JavaScript number is not an appropriate intermediate representation for exact token amounts.

Suggested fix

Validate the original string and convert it directly with integer arithmetic.

Example:

function parseUsdcAmount(value: unknown): bigint {
  if (typeof value !== "string") {
    throw new Error("Amount must be a string");
  }

  if (!/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(value)) {
    throw new Error(
      "Amount must be a positive decimal with at most 6 decimal places"
    );
  }

  const [whole, fraction = ""] = value.split(".");
  const atomic =
    BigInt(whole) * 1_000_000n +
    BigInt(fraction.padEnd(6, "0"));

  if (atomic <= 0n) {
    throw new Error("Amount must be greater than zero");
  }

  return atomic;
}

Then use the parsed atomic amount consistently for:

  • balance checks;
  • payout execution;
  • persisted transaction amounts;
  • response values.

Do not parse the same amount multiple times through different conversion paths.

Tests

Add test cases for:

"1"          -> 1000000
"1.5"        -> 1500000
"0.000001"   -> 1
"1USDC"      -> reject
"1.2abc"     -> reject
"0.0000009"  -> reject
"1e3"        -> reject unless explicitly supported
"Infinity"   -> reject
"NaN"        -> reject
""           -> reject
"-1"         -> reject
"0"          -> reject

Also verify that the amount used for balance validation is exactly the amount passed to the transfer implementation.

Acceptance criteria

  • The original amount string is validated completely.
  • Partially numeric strings are rejected.
  • More than six decimal places are rejected rather than truncated.
  • Conversion does not use parseFloat(), floating-point multiplication, or Math.floor().
  • Positive values cannot become zero atomic units.
  • Balance checks and execution use the same atomic amount.
  • Unit tests cover malformed and boundary values.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions