From 7aaa6035a3e92c53a20fd586e18d595bfa242482 Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Sun, 25 Oct 2026 17:32:14 +0530 Subject: [PATCH 1/2] fix: peer audit fixes - CV-FE-01: validate batchInfo fields (string batchId, integer index within batchSize) so a hash-valid hostile payload can no longer crash the Received page; render the batch label defensively. - CV-FE-09: number inputs blur on mouse wheel, so scrolling past an amount field can't silently change it. Fixed once in the shared Input. - Custom tokens: replace the "Verified" badge with "Not in token list" and a warning to check the address. Reading a token's contract only proves it is an ERC-20, not that it is the token its name claims. - Request Invoices: build no link without a connected wallet instead of one with an empty address and chain 1. - CV-OPS-01: README and .env.example point at the current Sepolia, Mordor and Amoy contracts; the retired Sepolia contract is listed as such. - Base: pass VITE_CONTRACT_ADDRESS_8453 to the Pages build. Base stays hidden until that secret is set. --- .github/workflows/deploy.yml | 1 + README.md | 24 +++++++-- frontend/.env.example | 16 +++--- .../src/components/CustomTokenDetails.jsx | 44 +++++++++++++++++ frontend/src/components/ui/input.jsx | 4 +- frontend/src/locales/en.json | 5 ++ frontend/src/page/CreateInvoice.jsx | 32 +----------- frontend/src/page/CreateInvoicesBatch.jsx | 30 +----------- frontend/src/page/GenerateLink.jsx | 49 ++++--------------- frontend/src/page/ReceivedInvoice.jsx | 6 +-- frontend/src/utils/invoicePayloadShape.js | 19 ++++++- frontend/src/utils/numberInputWheel.js | 19 +++++++ frontend/tests/utils/chainInvoice.test.js | 16 ++++++ .../tests/utils/invoicePayloadShape.test.js | 10 ++++ frontend/tests/utils/numberInputWheel.test.js | 29 +++++++++++ 15 files changed, 191 insertions(+), 113 deletions(-) create mode 100644 frontend/src/components/CustomTokenDetails.jsx create mode 100644 frontend/src/utils/numberInputWheel.js create mode 100644 frontend/tests/utils/numberInputWheel.test.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 181001d68..27881808a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,6 +62,7 @@ jobs: VITE_CONTRACT_ADDRESS_11155111: ${{ secrets.VITE_CONTRACT_ADDRESS_11155111 }} VITE_CONTRACT_ADDRESS_63: ${{ secrets.VITE_CONTRACT_ADDRESS_63 }} VITE_CONTRACT_ADDRESS_80002: ${{ secrets.VITE_CONTRACT_ADDRESS_80002 }} + VITE_CONTRACT_ADDRESS_8453: ${{ secrets.VITE_CONTRACT_ADDRESS_8453 }} # Ethereum Classic and Polygon are deliberately not injected. Both # still run the v1 contract, which stores payloads on-chain as strings # and has no key registry, so it does not match this ABI. The app diff --git a/README.md b/README.md index 217c754f1..1d00376b0 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,13 @@ npm run dev ### Frontend Configuration (`frontend/.env`) ```.env #Ethereum Sepolia (11155111) -VITE_CONTRACT_ADDRESS_11155111=0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b +VITE_CONTRACT_ADDRESS_11155111=0xff10Cda545E10656C2DcBEAB6C9eC375E136363a +#Mordor Testnet (63) +VITE_CONTRACT_ADDRESS_63=0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2 +#Polygon Amoy (80002) +VITE_CONTRACT_ADDRESS_80002=0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2 +#Base (8453) — blank until deployed +VITE_CONTRACT_ADDRESS_8453= #Ethereum Classic (61) — blank until redeployed, see note below VITE_CONTRACT_ADDRESS_61= #Polygon Mainnet (137) — blank until redeployed, see note below @@ -206,8 +212,8 @@ VITE_CONTRACT_ADDRESS_137= > ⚠️ Renaming the key registry functions changed their selectors, so only a > contract deployed from the current `contracts/src/Chainvoice.sol` will answer. -> The Sepolia address above is such a deployment — see -> [Deployments.md](./Deployments.md). Keys registered against an earlier +> The addresses above are such deployments, and +> [Deployments.md](./Deployments.md) is the record of every deployment. Keys registered against an earlier > deployment do not carry over; those users must register again. > > ⚠️ Ethereum Classic and Polygon are left blank on purpose. Both still run the @@ -237,9 +243,17 @@ VITE_RELAY_TIMEOUT_MS= ### Current (hash-based invoice storage) Stores only `keccak256` of the invoice data on-chain and exposes the public key -registry. Addresses are tracked in [Deployments.md](./Deployments.md). +registry. [Deployments.md](./Deployments.md) is the full record, including +constructor parameters and retired addresses. - Ethereum Sepolia (11155111) -```0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b``` +```0xff10Cda545E10656C2DcBEAB6C9eC375E136363a``` +- Mordor Testnet (63) +```0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2``` +- Polygon Amoy (80002) +```0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2``` + +Retired, kept for reference: Ethereum Sepolia ```0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b``` +(older ABI with `owner` and `fee`, which the current frontend cannot use). ### v1 (Mainnet Deployment — Jan 1) Stores the invoice payload on-chain as strings. Superseded, kept for reference. diff --git a/frontend/.env.example b/frontend/.env.example index 9bc66ec18..2ce4901ab 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,12 +1,13 @@ # env-copy # Renaming the registry functions changed their selectors, so only a contract -# deployed from the current contracts/src/Chainvoice.sol will answer. Sepolia -# below is such a deployment; see Deployments.md. Keys registered against any -# earlier deployment do not carry over — those users must register again. +# deployed from the current contracts/src/Chainvoice.sol will answer. The +# addresses below are such deployments; Deployments.md records every one. Keys +# registered against any earlier deployment do not carry over — those users +# must register again. #Ethereum Sepolia (11155111) -VITE_CONTRACT_ADDRESS_11155111=0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b +VITE_CONTRACT_ADDRESS_11155111=0xff10Cda545E10656C2DcBEAB6C9eC375E136363a # Left blank deliberately. Both chains still run the v1 contract, which stores # invoice payloads on-chain as strings and has no public key registry, so it @@ -17,10 +18,13 @@ VITE_CONTRACT_ADDRESS_11155111=0x65eb0ca96f972c5a0cdaa623a5b54650e499df5b VITE_CONTRACT_ADDRESS_61= # Mordor Testnet (63) -VITE_CONTRACT_ADDRESS_63= +VITE_CONTRACT_ADDRESS_63=0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2 # Polygon Amoy (80002) -VITE_CONTRACT_ADDRESS_80002= +VITE_CONTRACT_ADDRESS_80002=0xeF6669DDEd0078A8Cfdd758A40D194b1cC2120d2 + +# Base (8453), blank until deployed +VITE_CONTRACT_ADDRESS_8453= # Polygon Mainnet (137) VITE_CONTRACT_ADDRESS_137= diff --git a/frontend/src/components/CustomTokenDetails.jsx b/frontend/src/components/CustomTokenDetails.jsx new file mode 100644 index 000000000..0528254bb --- /dev/null +++ b/frontend/src/components/CustomTokenDetails.jsx @@ -0,0 +1,44 @@ +import { AlertTriangle } from "lucide-react"; +import { useTranslation } from "@/hooks/useTranslation"; +import { Badge } from "@/components/ui/badge"; +import { CopyButton } from "@/components/ui/copyButton"; + +/** + * A custom token read from its contract. Reading it proves only that the + * address is an ERC-20: anyone can deploy one called "USD Coin", so the + * address, not the name, is what the user has to check. + */ +export default function CustomTokenDetails({ token }) { + const { t } = useTranslation("customToken"); + + return ( +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx index 41ec05e41..ab362453f 100644 --- a/frontend/src/components/ui/input.jsx +++ b/frontend/src/components/ui/input.jsx @@ -1,8 +1,9 @@ import * as React from "react" import { cn } from "@/lib/utils" +import { getInputWheelHandler } from "@/utils/numberInputWheel" -const Input = React.forwardRef(({ className, type, ...props }, ref) => { +const Input = React.forwardRef(({ className, type, onWheel, ...props }, ref) => { return ( ( { className )} ref={ref} + onWheel={getInputWheelHandler(type, onWheel)} {...props} />) ); }) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 77ea71f74..9e2ecc050 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -180,5 +180,10 @@ "walletCancelled": "You declined the cancellation in your wallet.", "generic": "Failed to cancel the invoice. Please try again." } + }, + "customToken": { + "unlisted": "Not in token list", + "decimals": "Decimals: {{decimals}}", + "checkAddress": "Anyone can deploy a token with any name. Make sure this address belongs to the token you expect before using it." } } diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index d9411a29a..86dff7bd1 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -25,7 +25,6 @@ import { } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { - Badge, CalendarIcon, CheckCircle2, Coins, @@ -45,7 +44,7 @@ import { ERC20_ABI } from "@/contractsABI/ERC20_ABI"; import WalletConnectionAlert from "../components/WalletConnectionAlert"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; -import { CopyButton } from "@/components/ui/copyButton"; +import CustomTokenDetails from "@/components/CustomTokenDetails"; import CountryPicker from "@/components/CountryPicker"; import { useTokenList, isTestnet } from "@/hooks/useTokenList"; import { @@ -1307,34 +1306,7 @@ function CreateInvoice() { {tokenVerificationState === "success" && verifiedToken && (
-
-
- -
-
-

- {verifiedToken.name} ( - {verifiedToken.symbol}) -

- - Verified ✓ - -
-
- - {verifiedToken.address} - - -
-

- Decimals: {String(verifiedToken.decimals)} • - Contract verified and ready to use -

-
-
-
+ diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx index 1cbf82fa4..e87f759b9 100644 --- a/frontend/src/page/CreateInvoicesBatch.jsx +++ b/frontend/src/page/CreateInvoicesBatch.jsx @@ -24,7 +24,6 @@ import { } from "@/components/ui/popover"; import { Calendar } from "@/components/ui/calendar"; import { - Badge, CalendarIcon, CheckCircle2, Coins, @@ -54,9 +53,9 @@ import TokenIntegrationRequest from "@/components/TokenIntegrationRequest"; import { ERC20_ABI } from "@/contractsABI/ERC20_ABI"; import WalletConnectionAlert from "../components/WalletConnectionAlert"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; +import CustomTokenDetails from "@/components/CustomTokenDetails"; import { getMaxInvoicesPerBatch } from "@/utils/batchLimits"; import { AmountTypeToggle } from "../components/AmountTypeToggle"; -import { CopyButton } from "@/components/ui/copyButton"; import CountryPicker from "@/components/CountryPicker"; import { getLineAmountDetails, @@ -1103,32 +1102,7 @@ function CreateInvoicesBatch() { {tokenVerificationState === "success" && verifiedToken && (
-
-
- -
-
-

- {verifiedToken.name} ({verifiedToken.symbol}) -

- - Verified - -
-
- - {verifiedToken.address} - - -
-

- Decimals: {String(verifiedToken.decimals)} -

-
-
-
+
)} diff --git a/frontend/src/page/GenerateLink.jsx b/frontend/src/page/GenerateLink.jsx index d47a66e72..415a33fd1 100644 --- a/frontend/src/page/GenerateLink.jsx +++ b/frontend/src/page/GenerateLink.jsx @@ -7,7 +7,6 @@ import { Check, Wallet, Loader2, - CheckCircle2, XCircle, Coins, } from "lucide-react"; @@ -18,8 +17,7 @@ import { Label } from "../components/ui/label"; import WalletConnectionAlert from "@/components/WalletConnectionAlert"; import TokenIntegrationRequest from "@/components/TokenIntegrationRequest"; import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker"; -import { CopyButton } from "@/components/ui/copyButton"; -import { Badge } from "@/components/ui/badge"; +import CustomTokenDetails from "@/components/CustomTokenDetails"; import { BrowserProvider, ethers } from "ethers"; import { ERC20_ABI } from "@/contractsABI/ERC20_ABI"; import { PAGE_CONTAINER } from "@/utils/layout"; @@ -69,18 +67,22 @@ const GenerateLink = () => { // Never generate a link using an unverified custom token. if (customTokenUnverified) return ""; + // The link bills the connected wallet on its chain; without one it would + // carry an empty address and a guessed chain. + if (!isConnected || !address || !chainId) return ""; + // Support deployments served from a subdirectory. const appRoot = `${window.location.origin}${import.meta.env.BASE_URL}`; if (!tokenToUse) { - return `${appRoot}#/dashboard/create?clientAddress=${address || ""}`; + return `${appRoot}#/dashboard/create?clientAddress=${address}`; } const params = new URLSearchParams({ - clientAddress: address || "", + clientAddress: address, tokenAddress: tokenToUse.address || tokenToUse.contract_address, customToken: useCustomToken ? "true" : "false", - chain: chainId?.toString() || "1", + chain: chainId.toString(), }); if (amount) { @@ -394,39 +396,7 @@ const GenerateLink = () => { {tokenVerificationState === "success" && verifiedToken && (
-
-
- - -
-
-

- {verifiedToken.name} ( - {verifiedToken.symbol}) -

- - - Verified ✓ - -
- -
- - {verifiedToken.address} - - - -
- -

- Decimals: {String(verifiedToken.decimals)}{" "} - • Contract verified and ready to use -

-
-
-
+ {
diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx index 56ed1f175..382e6e3cb 100644 --- a/frontend/src/page/ReceivedInvoice.jsx +++ b/frontend/src/page/ReceivedInvoice.jsx @@ -1788,9 +1788,9 @@ function ReceivedInvoice() {
} - label={`Batch #${invoice.batchInfo.batchId.slice( - -4 - )} (${invoice.batchInfo.index + 1}/${invoice.batchInfo.batchSize + label={`Batch #${String( + invoice.batchInfo.batchId + ).slice(-4)} (${invoice.batchInfo.index + 1}/${invoice.batchInfo.batchSize })`} size="small" variant="outlined" diff --git a/frontend/src/utils/invoicePayloadShape.js b/frontend/src/utils/invoicePayloadShape.js index 5cfe1e4c3..175b9753a 100644 --- a/frontend/src/utils/invoicePayloadShape.js +++ b/frontend/src/utils/invoicePayloadShape.js @@ -47,6 +47,23 @@ function isFlatRecord(value, textFields) { ); } +/** + * Batch metadata as CreateInvoicesBatch writes it. The Received page slices + * `batchId` and does arithmetic on `index`, so a flat object is not enough. + */ +function isBatchInfo(value) { + return ( + isFlatRecord(value, ["batchId", "batchType"]) && + typeof value.batchId === "string" && + value.batchId !== "" && + Number.isInteger(value.batchSize) && + value.batchSize >= 1 && + Number.isInteger(value.index) && + value.index >= 0 && + value.index < value.batchSize + ); +} + /** * Name the first part of the payload with the wrong shape, or null. * @@ -77,7 +94,7 @@ export function getPayloadShapeError(payload) { return "items"; } - if (!isAbsent(payload.batchInfo) && !isFlatRecord(payload.batchInfo, [])) { + if (!isAbsent(payload.batchInfo) && !isBatchInfo(payload.batchInfo)) { return "batchInfo"; } diff --git a/frontend/src/utils/numberInputWheel.js b/frontend/src/utils/numberInputWheel.js new file mode 100644 index 000000000..354c6a8db --- /dev/null +++ b/frontend/src/utils/numberInputWheel.js @@ -0,0 +1,19 @@ +/** + * The wheel handler for an input of the given type. + * + * Browsers step a focused number input on every wheel tick, so scrolling the + * page with the pointer over an amount field silently changes the amount. + * Blurring the field first lets the wheel scroll the page instead. + * + * @param {string|undefined} type - the input's type attribute + * @param {Function} [onWheel] - the caller's own handler, still called + * @returns {Function|undefined} + */ +export function getInputWheelHandler(type, onWheel) { + if (type !== "number") return onWheel; + + return (event) => { + onWheel?.(event); + event.currentTarget.blur(); + }; +} diff --git a/frontend/tests/utils/chainInvoice.test.js b/frontend/tests/utils/chainInvoice.test.js index 02d4fc417..c4d631544 100644 --- a/frontend/tests/utils/chainInvoice.test.js +++ b/frontend/tests/utils/chainInvoice.test.js @@ -158,6 +158,22 @@ describe("buildChainInvoice", () => { expect(isInvoicePayable(invoice)).toBe(false); }); + test.each([ + ["empty", {}], + ["with a numeric id", { batchId: 12345, batchSize: 2, index: 0 }], + ])("does not show committed batch info that is %s", (_, batchInfo) => { + // The Received page slices batchInfo.batchId when it renders the row. + const payload = makePayload({ batchInfo }); + const invoice = buildChainInvoice(makeRaw({ payload }), payload, usdcToken); + + expect(invoice).toMatchObject({ + _onChainOnly: true, + _hashMismatch: false, + _payloadMismatch: "invoice format", + }); + expect(invoice.batchInfo).toBeUndefined(); + }); + test("flags a payload whose decimals differ from the token contract's", () => { // Self-consistent at 18 decimals, but the token has 6, so the contract // charges 10^12 USDC rather than the 1 USDC the payload shows. diff --git a/frontend/tests/utils/invoicePayloadShape.test.js b/frontend/tests/utils/invoicePayloadShape.test.js index a482d2aff..fb9d2ea1c 100644 --- a/frontend/tests/utils/invoicePayloadShape.test.js +++ b/frontend/tests/utils/invoicePayloadShape.test.js @@ -61,6 +61,16 @@ describe("getPayloadShapeError", () => { ["a nested object in an item", { items: [{ description: "x", qty: { n: 1 } }] }, "items"], ["a number as an item description", { items: [{ description: 5 }] }, "items"], ["batch info that is a string", { batchInfo: "batch" }, "batchInfo"], + ["empty batch info", { batchInfo: {} }, "batchInfo"], + ["a number as the batch id", { batchInfo: { batchId: 12345, batchSize: 2, index: 0 } }, "batchInfo"], + ["an empty batch id", { batchInfo: { batchId: "", batchSize: 2, index: 0 } }, "batchInfo"], + ["a missing batch size", { batchInfo: { batchId: "batch_1", index: 0 } }, "batchInfo"], + ["a zero batch size", { batchInfo: { batchId: "batch_1", batchSize: 0, index: 0 } }, "batchInfo"], + ["a text batch index", { batchInfo: { batchId: "batch_1", batchSize: 2, index: "0" } }, "batchInfo"], + ["a fractional batch index", { batchInfo: { batchId: "batch_1", batchSize: 2, index: 0.5 } }, "batchInfo"], + ["a negative batch index", { batchInfo: { batchId: "batch_1", batchSize: 2, index: -1 } }, "batchInfo"], + ["a batch index past the batch size", { batchInfo: { batchId: "batch_1", batchSize: 2, index: 2 } }, "batchInfo"], + ["a number as the batch type", { batchInfo: { batchId: "batch_1", batchSize: 2, index: 0, batchType: 1 } }, "batchInfo"], ["an object as the due date", { dueDate: { y: 2026 } }, "dueDate"], ["an invalid Date as the issue date", { issueDate: new Date("not a date") }, "issueDate"], ["an object as the amount", { amountDue: { value: "1" } }, "amountDue"], diff --git a/frontend/tests/utils/numberInputWheel.test.js b/frontend/tests/utils/numberInputWheel.test.js new file mode 100644 index 000000000..0e2709bcc --- /dev/null +++ b/frontend/tests/utils/numberInputWheel.test.js @@ -0,0 +1,29 @@ +import { jest } from "@jest/globals"; +import { getInputWheelHandler } from "../../src/utils/numberInputWheel.js"; + +const makeWheelEvent = () => ({ currentTarget: { blur: jest.fn() } }); + +describe("getInputWheelHandler", () => { + test("blurs a number input so the wheel cannot step its value", () => { + const event = makeWheelEvent(); + getInputWheelHandler("number")(event); + + expect(event.currentTarget.blur).toHaveBeenCalledTimes(1); + }); + + test("still calls the caller's handler on a number input", () => { + const onWheel = jest.fn(); + const event = makeWheelEvent(); + getInputWheelHandler("number", onWheel)(event); + + expect(onWheel).toHaveBeenCalledWith(event); + expect(event.currentTarget.blur).toHaveBeenCalledTimes(1); + }); + + test.each(["text", "email", undefined])("leaves a %s input alone", (type) => { + const onWheel = jest.fn(); + + expect(getInputWheelHandler(type, onWheel)).toBe(onWheel); + expect(getInputWheelHandler(type)).toBeUndefined(); + }); +}); From a3e5e94123d55bd4ac7f9c336c671748d2e03910 Mon Sep 17 00:00:00 2001 From: Atharva0506 Date: Sun, 25 Oct 2026 17:46:36 +0530 Subject: [PATCH 2/2] fix(i18n): translate the Request Invoices link placeholder --- frontend/src/locales/en.json | 3 +++ frontend/src/page/GenerateLink.jsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9e2ecc050..207256b9d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -185,5 +185,8 @@ "unlisted": "Not in token list", "decimals": "Decimals: {{decimals}}", "checkAddress": "Anyone can deploy a token with any name. Make sure this address belongs to the token you expect before using it." + }, + "generateLink": { + "connectWalletPlaceholder": "Connect your wallet to generate a link" } } diff --git a/frontend/src/page/GenerateLink.jsx b/frontend/src/page/GenerateLink.jsx index 415a33fd1..5191bd78f 100644 --- a/frontend/src/page/GenerateLink.jsx +++ b/frontend/src/page/GenerateLink.jsx @@ -24,10 +24,12 @@ import { PAGE_CONTAINER } from "@/utils/layout"; import { cn } from "@/lib/utils"; import toast from "react-hot-toast"; import { verifyTokenContract } from "../utils/paymentToken.js"; +import { useTranslation } from "../hooks/useTranslation"; const GenerateLink = () => { const { address, isConnected, chainId } = useAccount(); const { data: walletClient } = useWalletClient(); + const { t } = useTranslation("generateLink"); const [copied, setCopied] = useState(false); const [amount] = useState(""); @@ -492,7 +494,7 @@ const GenerateLink = () => {