Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-suggested-fees-pct-bigint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@across-protocol/app-sdk": patch
---

Tighten percentageString validator to integer strings and protect BigInt pct conversions in parseSuggestedFees
12 changes: 8 additions & 4 deletions packages/sdk/src/actions/getSuggestedFees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ export async function getSuggestedFees({
return parseSuggestedFees(data);
}

function toBigIntPct(value: string): bigint {
return BigInt(value.includes(".") ? value.split(".")[0] || "0" : value);
}

export function parseSuggestedFees(
raw: SuggestedFeesApiResponse,
): GetSuggestedFeesReturnType {
Expand All @@ -209,19 +213,19 @@ export function parseSuggestedFees(
destinationSpokePoolAddress: raw.destinationSpokePoolAddress as Address,
fillDeadline: Number(raw.fillDeadline),
totalRelayFee: {
pct: BigInt(raw.totalRelayFee.pct),
pct: toBigIntPct(raw.totalRelayFee.pct),
total: BigInt(raw.totalRelayFee.total),
},
relayerCapitalFee: {
pct: BigInt(raw.relayerCapitalFee.pct),
pct: toBigIntPct(raw.relayerCapitalFee.pct),
total: BigInt(raw.relayerCapitalFee.total),
},
relayerGasFee: {
pct: BigInt(raw.relayerGasFee.pct),
pct: toBigIntPct(raw.relayerGasFee.pct),
total: BigInt(raw.relayerGasFee.total),
},
lpFee: {
pct: BigInt(raw.lpFee.pct),
pct: toBigIntPct(raw.lpFee.pct),
total: BigInt(raw.lpFee.total),
},
limits: {
Expand Down
24 changes: 13 additions & 11 deletions packages/sdk/src/api/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ export const ethereumAddress = z.string().regex(/^0x[a-fA-F0-9]{40}$/, {
});

// Solana base58 address (32-44 chars)
export const solanaAddress = z
.string()
.regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/, {
message: "Invalid Solana address format",
});
export const solanaAddress = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/, {
message: "Invalid Solana address format",
});

// EVM or Solana address
export const anyChainAddress = z.union([ethereumAddress, solanaAddress]);
Expand All @@ -21,7 +19,7 @@ export const bigNumberString = z.string().regex(/^-?\d+$/, {
message: "Invalid BigNumber string format",
});

export const percentageString = z.string().regex(/^\d+(\.\d+)?$/, {
export const percentageString = z.string().regex(/^\d+$/, {
message: "Invalid percentage string format",
});

Expand All @@ -33,11 +31,15 @@ export const positiveIntString = z.string().regex(/^\d+$/, {
message: "Invalid positive integer string format",
});

export const positiveFloatString = (max: number) => z.string().regex(/^\d+(\.\d+)?$/, {
message: "Invalid positive float string format",
}).refine(val => parseFloat(val) <= max, {
message: `Value must be <= ${max}`,
});
export const positiveFloatString = (max: number) =>
z
.string()
.regex(/^\d+(\.\d+)?$/, {
message: "Invalid positive float string format",
})
.refine((val) => parseFloat(val) <= max, {
message: `Value must be <= ${max}`,
});

export const booleanString = z.string().regex(/^(true|false)$/, {
message: "Invalid boolean string format",
Expand Down
58 changes: 58 additions & 0 deletions packages/sdk/test/unit/api/suggested-fees.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { percentageString } from "../../../src/api/validators.js";
import {
getSuggestedFees,
GetSuggestedFeesParams,
parseSuggestedFees,
} from "../../../src/actions/getSuggestedFees.js";
import { suggestedFeesResponseJsonSchema } from "../../../src/api/suggested-fees.js";
import { MAINNET_API_URL } from "../../../src/constants/index.js";
Expand Down Expand Up @@ -49,4 +51,60 @@ describe("suggested-fees", async () => {
const compareRawWithParsed = checkFields(raw, parsed);
expect(compareRawWithParsed).toBe(true);
});
test("percentageString rejects decimal strings and accepts integer strings", () => {
expect(percentageString.safeParse("0.5").success).toBe(false);
expect(percentageString.safeParse("0.0005").success).toBe(false);
expect(percentageString.safeParse("76950972772560").success).toBe(true);
expect(percentageString.safeParse("0").success).toBe(true);
});

test("parseSuggestedFees safely converts decimal and integer pct fields to bigint", () => {
const mockRaw: any = {
outputAmount: "1999846098054454880",
estimatedFillTimeSec: 3,
capitalFeePct: "76875000000000",
capitalFeeTotal: "153750000000000",
relayGasFeePct: "75972772560",
relayGasFeeTotal: "151945545120",
relayFeePct: "76950972772560",
relayFeeTotal: "153901945545120",
lpFeePct: "0",
timestamp: "1789091423",
isAmountTooLow: false,
quoteBlock: "25950980",
exclusiveRelayer: "0xaD03abC6223e9DEf79B3Feb624F54303A372f0c7",
exclusivityDeadline: 1789091551,
spokePoolAddress: "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5",
destinationSpokePoolAddress: "0x6f26Bf09B1C792e3228e5467807a900A503c0281",
totalRelayFee: { pct: "0.5", total: "153901945545120" },
relayerCapitalFee: { pct: "76875000000000", total: "153750000000000" },
relayerGasFee: { pct: "75972772560", total: "151945545120" },
lpFee: { pct: "0", total: "0" },
limits: {
minDeposit: "203996703413272",
maxDeposit: "1143902177198408031000",
maxDepositInstant: "41759844987637500165",
maxDepositShortDelay: "1143902177198408031000",
recommendedDepositInstant: "41759844987637500165",
},
fillDeadline: "1789098623",
inputToken: {
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
symbol: "WETH",
decimals: 18,
chainId: 1,
},
outputToken: {
address: "0x4200000000000000000000000000000000000006",
symbol: "WETH",
decimals: 18,
chainId: 10,
},
};

expect(() => parseSuggestedFees(mockRaw)).not.toThrow();
const parsed = parseSuggestedFees(mockRaw);
expect(parsed.totalRelayFee.pct).toBe(0n);
expect(parsed.relayerCapitalFee.pct).toBe(76875000000000n);
});
});