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
-
Authenticate as a user with a funded wallet.
-
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"
}'
-
Observe the parsing behavior:
parseFloat("1USDC") === 1
-
The request passes the current validation because the parsed result is a positive number.
-
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:
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:
- uses
parseFloat(), which accepts partially numeric strings;
- validates only the parsed numeric prefix;
- multiplies a JavaScript floating-point number by
1_000_000;
- applies
Math.floor(), silently truncating excess precision;
- 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
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 andMath.floor().parseFloat()accepts a valid numeric prefix and ignores trailing invalid characters.For example:
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.tsAmount conversion:
arc-fintech/app/api/payout/route.ts
Lines 59 to 63 in b7855eb
Amount validation and use:
arc-fintech/app/api/payout/route.ts
Lines 81 to 135 in b7855eb
Severity
Medium
Steps to reproduce
Authenticate as a user with a funded wallet.
Submit a payout request with a partially numeric amount:
Observe the parsing behavior:
The request passes the current validation because the parsed result is a positive number.
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:
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:
Examples that should be rejected:
The exact accepted syntax may differ, but malformed or over-precision values must not be silently reinterpreted.
Actual behavior
The endpoint:
parseFloat(), which accepts partially numeric strings;1_000_000;Math.floor(), silently truncating excess precision;Impact
This may cause:
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:
parseFloat()is not a strict decimal validator, and JavaScriptnumberis not an appropriate intermediate representation for exact token amounts.Suggested fix
Validate the original string and convert it directly with integer arithmetic.
Example:
Then use the parsed atomic amount consistently for:
Do not parse the same amount multiple times through different conversion paths.
Tests
Add test cases for:
Also verify that the amount used for balance validation is exactly the amount passed to the transfer implementation.
Acceptance criteria
parseFloat(), floating-point multiplication, orMath.floor().