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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions