From 4fded9789eb7fc53fcd6b124bb23db1925baaa1c Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Tue, 20 Jan 2026 16:30:49 +0300 Subject: [PATCH 1/5] feat(referral): prevent self-referral by validating wallet address Update the useReferralCode hook to ensure the decoded referral address does not match the active wallet. The hook now invalidates self-referrals and automatically clears them from local storage on connection. --- src/hooks/tractor/useReferralCode.ts | 81 ++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/src/hooks/tractor/useReferralCode.ts b/src/hooks/tractor/useReferralCode.ts index 0d080127..63b4c54e 100644 --- a/src/hooks/tractor/useReferralCode.ts +++ b/src/hooks/tractor/useReferralCode.ts @@ -1,5 +1,7 @@ -import { isValidReferralCode } from "@/utils/referral"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { decodeReferralAddress, isValidReferralCode } from "@/utils/referral"; +import { stringEq } from "@/utils/string"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { useAccount } from "wagmi"; const REFERRAL_CODE_STORAGE_KEY = "pinto-referral"; const DEBOUNCE_DELAY = 100; @@ -81,37 +83,72 @@ const saveToLocalStorage = (value: string) => { * Uses global state for cross-component synchronization. */ export function useReferralCode() { + const { address } = useAccount(); const referralCode = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); // Get valid referral code from localStorage (for conditional rendering) const validReferralCodeFromStorage = useSyncExternalStore(subscribeStorage, getStorageSnapshot, getStorageSnapshot); - const setReferralCode = useCallback((value: string) => { - const trimmed = value.trim(); + const setReferralCode = useCallback( + (value: string) => { + const trimmed = value.trim(); - // Always update global state for immediate UI feedback (so user can type) - globalReferralCode = trimmed; - notifyListeners(); + // Always update global state for immediate UI feedback (so user can type) + globalReferralCode = trimmed; + notifyListeners(); - // Save to localStorage only if valid, otherwise clear it - if (!trimmed) { - // Empty value: clear localStorage - saveToLocalStorage(""); - } else if (isValidReferralCode(trimmed)) { - // Valid code: save to localStorage - saveToLocalStorage(trimmed); - } else { - // Invalid code: clear localStorage - saveToLocalStorage(""); - } - // Invalid codes are kept in global state for typing feedback, but localStorage is cleared - }, []); + // Save to localStorage only if valid, otherwise clear it + if (!trimmed) { + // Empty value: clear localStorage + saveToLocalStorage(""); + } else { + // Check if referral code is valid and doesn't belong to connected wallet + const decodedAddress = decodeReferralAddress(trimmed); + const isValid = isValidReferralCode(trimmed); + const isOwnCode = address && decodedAddress && stringEq(decodedAddress, address); + + if (isValid && !isOwnCode) { + // Valid code and not own code: save to localStorage + saveToLocalStorage(trimmed); + } else { + // Invalid code or own code: clear localStorage + saveToLocalStorage(""); + } + } + // Invalid codes are kept in global state for typing feedback, but localStorage is cleared + }, + [address], + ); // Validate referral code for real-time validation const isReferralCodeValid = useMemo(() => { if (!referralCode) return false; - return isValidReferralCode(referralCode); - }, [referralCode]); + + // Check if referral code is valid + if (!isValidReferralCode(referralCode)) return false; + + // Check if referral code belongs to connected wallet + // If wallet is not connected (address is undefined), we can't check, so allow it + if (!address) return true; + + const decodedAddress = decodeReferralAddress(referralCode); + if (decodedAddress && stringEq(decodedAddress, address)) { + return false; // Invalid if it's the user's own code + } + + return true; + }, [referralCode, address]); + + // Clean up localStorage if wallet connects and stored code is user's own code + useEffect(() => { + if (!address || !validReferralCodeFromStorage) return; + + const decodedAddress = decodeReferralAddress(validReferralCodeFromStorage); + if (decodedAddress && stringEq(decodedAddress, address)) { + // Clear localStorage if stored code belongs to connected wallet + saveToLocalStorage(""); + } + }, [address, validReferralCodeFromStorage]); return { referralCode, // For input field (can be invalid during typing) From 542b4820c2177e0a8e6ee5f12fb2dbda32937e1a Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Tue, 20 Jan 2026 16:58:38 +0300 Subject: [PATCH 2/5] fix(field): prevent sow transactions when soil is unavailable Updates the sow component to validate soil availability before submission and reflects this state in the ui. The action button now displays a specific error message and is disabled when soil is zero. --- src/pages/field/actions/Sow.tsx | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/pages/field/actions/Sow.tsx b/src/pages/field/actions/Sow.tsx index ad797141..f6d34c1f 100644 --- a/src/pages/field/actions/Sow.tsx +++ b/src/pages/field/actions/Sow.tsx @@ -214,6 +214,11 @@ function Sow({ isMorning, onShowOrder }: SowProps) { if (inputError) { throw new Error("Invalid input"); } + // Check soil availability before submitting + // This prevents transaction submission even if UI state is bypassed + if (totalSoilLoading || !totalSoil || totalSoil.lte(0)) { + throw new Error("No Soil available"); + } // Track sow submission trackSimpleEvent(ANALYTICS_EVENTS.FIELD.SOW_SUBMIT, { @@ -346,6 +351,8 @@ function Sow({ isMorning, onShowOrder }: SowProps) { currentTemperature, inputError, referralAddress, + totalSoil, + totalSoilLoading, ]); // Callbacks @@ -435,7 +442,9 @@ function Sow({ isMorning, onShowOrder }: SowProps) { const initializing = !didSetPreferred || (hasSoil ? maxBuyQuery.isLoading : false); const isLoading = (numIn > 0 && loading) || (pods?.lte(0) && numIn > 0); - const ready = pods?.gt(0) && podLine.gte(0) && (hasSoil ? maxBuy?.gt(0) && amountInTV.gt(0) : true); + // If there's no soil, ready should be false (can't sow without soil) + // If there's soil, check that maxBuy is available and amount is greater than 0 + const ready = pods?.gt(0) && podLine.gte(0) && (hasSoil ? maxBuy?.gt(0) && amountInTV.gt(0) : false); const tokenBalance = fromSilo ? depositedByWhitelistedToken.get(tokenIn) @@ -447,7 +456,10 @@ function Sow({ isMorning, onShowOrder }: SowProps) { const ctaDisabled = isLoading || isConfirming || submitting || !ready || inputError || !canProceed; - const buttonText = inputError ? "Amount too large" : "Sow"; + // Determine button text based on error conditions + // If no soil available, show "No Soil available" (consistent with warning message) + const noSoilAvailable = !hasSoil && !totalSoilLoading; + const buttonText = inputError ? "Amount too large" : noSoilAvailable ? "No Soil available" : "Sow"; return ( @@ -613,9 +625,9 @@ function Sow({ isMorning, onShowOrder }: SowProps) { @@ -624,9 +636,9 @@ function Sow({ isMorning, onShowOrder }: SowProps) { Date: Tue, 20 Jan 2026 17:11:12 +0300 Subject: [PATCH 3/5] fix(tractor): update empty table type and order types definition --- src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx index 196fd4c0..0579cc06 100644 --- a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx +++ b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx @@ -48,7 +48,7 @@ interface TractorOrdersPanelProps { initialFilters?: Partial; } -const ORDER_TYPES: OrderType[] = ["sow", "convertUp"] as const; +const ORDER_TYPES: OrderType[] = ["sow", "convertUp"]; function TractorOrdersPanelGeneric({ orderTypes: _orderTypes = ORDER_TYPES, @@ -271,7 +271,7 @@ function TractorOrdersPanelGeneric({ } if (!unifiedOrders.length) { - return ; + return ; } return ( From e0471d2915105e027ab27449ca1303b3fdb36c0e Mon Sep 17 00:00:00 2001 From: feyyazcigim Date: Tue, 20 Jan 2026 18:10:02 +0300 Subject: [PATCH 4/5] feat(tractor): improve order management and UI consistency Implement referral code diffing in the review dialog and refine comparison logic for order parameters. Optimize tractor query invalidation after transactions and fix event propagation and portal issues in the sow order form. --- .../Tractor/ModifySowOrderDialog.tsx | 47 +++++++++++++++---- .../farmer-orders/TractorOrdersPanel.tsx | 14 +++--- .../Tractor/form/SowOrderV0Fields.tsx | 16 +++++-- .../Tractor/form/SowOrderV0Schema.ts | 2 + src/components/ui/Popover.tsx | 6 +-- 5 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/components/Tractor/ModifySowOrderDialog.tsx b/src/components/Tractor/ModifySowOrderDialog.tsx index abbf79a5..e8d56e42 100644 --- a/src/components/Tractor/ModifySowOrderDialog.tsx +++ b/src/components/Tractor/ModifySowOrderDialog.tsx @@ -27,12 +27,13 @@ import useTransaction from "@/hooks/useTransaction"; import { RequisitionEvent, SowBlueprintData, decodeSowTractorData, prepareRequisitionForTxn } from "@/lib/Tractor"; import { useGetBlueprintHash } from "@/lib/Tractor/blueprint"; import { Blueprint, ExtendedTractorTokenStrategy, Requisition, TractorTokenStrategy } from "@/lib/Tractor/types"; +import { queryKeys } from "@/state/queryKeys"; import useTractorOperatorAverageTipPaid from "@/state/tractor/useTractorOperatorAverageTipPaid"; import { useFarmerSilo } from "@/state/useFarmerSilo"; import { useChainConstant } from "@/utils/chain"; import { formatter } from "@/utils/format"; import { encodeReferralAddress, isValidReferralCode } from "@/utils/referral"; -import { postSanitizedSanitizedValue, sanitizeNumericInputValue } from "@/utils/string"; +import { postSanitizedSanitizedValue, sanitizeNumericInputValue, stringEq } from "@/utils/string"; import { tokensEqual } from "@/utils/token"; import { cn } from "@/utils/utils"; import { ArrowRightIcon } from "@radix-ui/react-icons"; @@ -172,7 +173,7 @@ export default function ModifyTractorOrderDialog({ // Sync hook referral code changes to form useEffect(() => { - if (hookReferralCode && hookReferralCode !== form.getValues("referralCode")) { + if (!stringEq(hookReferralCode, form.getValues("referralCode"))) { form.setValue("referralCode", hookReferralCode); } }, [hookReferralCode, form]); @@ -349,7 +350,7 @@ export default function ModifyTractorOrderDialog({ -
+
🚜 Update Conditions for automated Sowing
@@ -522,6 +523,7 @@ export default function ModifyTractorOrderDialog({ operatorPasteInstrs={state.operatorPasteInstructions} blueprint={state.blueprint} getStrategyProps={getStrategyProps} + currentReferralCode={hookReferralCode} /> )} @@ -541,6 +543,7 @@ interface ModifyTractorOrderReviewDialogProps { operatorPasteInstrs: `0x${string}`[]; blueprint: Blueprint; getStrategyProps: ReturnType; + currentReferralCode: string; } function ModifyTractorOrderReviewDialog({ @@ -551,14 +554,15 @@ function ModifyTractorOrderReviewDialog({ orderData, getStrategyProps, blueprint, + currentReferralCode, }: ModifyTractorOrderReviewDialogProps) { const { address } = useAccount(); const protocolAddress = useProtocolAddress(); const queryClient = useQueryClient(); const valueDiffs = useMemo( - () => getDiffs(getMapping(existingOrder, orderData, getStrategyProps)), - [existingOrder, orderData, getStrategyProps], + () => getDiffs(getMapping(existingOrder, orderData, getStrategyProps, currentReferralCode)), + [existingOrder, orderData, getStrategyProps, currentReferralCode], ); // Use the imported Tractor utilities @@ -569,7 +573,8 @@ function ModifyTractorOrderReviewDialog({ successMessage: "Order modified successfully", errorMessage: "Failed to modify order", successCallback: () => { - queryClient.invalidateQueries(); + // Invalidate tractor-related queries to refresh order data + queryClient.invalidateQueries({ queryKey: queryKeys.base.tractor }); onOpenChange(false); if (onSuccess) { onSuccess(); @@ -748,7 +753,7 @@ const RenderConstantParam = (props: ValueDiff) => { const getConstantParamValue = () => { try { if (typeof prev === "string") { - return prev; + return prev || "N/A"; } else if (typeof prev === "boolean") { return prev ? "Yes" : "No"; } else if (prev instanceof TokenValue) { @@ -828,10 +833,22 @@ const getMapping = ( requisition: RequisitionEvent, orderData: OrderData, getStrategyProps: ReturnType, + currentReferralCode: string, ) => { const existing = requisition.decodedData; if (!existing) return undefined; + // Extract referral code from existing order + let existingReferralCode = ""; + try { + const decodedResult = decodeSowTractorData(requisition.requisition.blueprint.data); + if (decodedResult && "blueprintData" in decodedResult && decodedResult.referralAddress) { + existingReferralCode = encodeReferralAddress(decodedResult.referralAddress) || ""; + } + } catch (e) { + console.debug("Could not extract referral address from existing order:", e); + } + return { totalAmount: { label: "Total Amount", @@ -881,6 +898,11 @@ const getMapping = ( prev: postSanitizedSanitizedValue(existing.operatorParams.operatorTipAmountAsString, 6).tv, curr: postSanitizedSanitizedValue(orderData.operatorTip, 6).tv, }, + referralCode: { + label: "Referral Code", + prev: existingReferralCode, + curr: currentReferralCode, + }, }; }; @@ -916,7 +938,16 @@ const getDiffs = (mapping: ReturnType) => { curr: curr ? "Yes" : "No", }; } - } else if (typeof prev === "object" && "type" in prev) { + } else if (typeof prev === "string" && typeof curr === "string") { + if (prev !== curr) { + hasChanged = true; + valueDiff = { + label, + prev: prev || "N/A", + curr: curr || "N/A", + }; + } + } else if (typeof prev === "object" && prev !== null && "type" in prev) { const current = curr as ExtendedTractorTokenStrategy; if ( prev.type !== current.type || diff --git a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx index 0579cc06..4ee22483 100644 --- a/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx +++ b/src/components/Tractor/farmer-orders/TractorOrdersPanel.tsx @@ -9,6 +9,7 @@ import { useGetTractorTokenStrategyWithBlueprint } from "@/hooks/tractor/useGetT import useTransaction from "@/hooks/useTransaction"; import { PublisherTractorExecution } from "@/lib/Tractor"; import type { Blueprint } from "@/lib/Tractor"; +import { queryKeys } from "@/state/queryKeys"; import { useTractorConvertUpOrderbook } from "@/state/tractor/useTractorConvertUpOrders"; import usePublisherTractorExecutions from "@/state/tractor/useTractorExecutions"; import useTractorOperatorAverageTipPaid from "@/state/tractor/useTractorOperatorAverageTipPaid"; @@ -17,6 +18,7 @@ import { tryExtractErrorMessage } from "@/utils/error"; import { stringEq } from "@/utils/string"; import { MayArray } from "@/utils/types.generic"; import { arrayify } from "@/utils/utils"; +import { useQueryClient } from "@tanstack/react-query"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useAccount } from "wagmi"; @@ -60,6 +62,7 @@ function TractorOrdersPanelGeneric({ const { address } = useAccount(); const protocolAddress = useProtocolAddress(); const getStrategyProps = useGetTractorTokenStrategyWithBlueprint(); + const queryClient = useQueryClient(); // State for dialogs and filters const [selectedOrder, setSelectedOrder] = useState(null); @@ -185,14 +188,9 @@ function TractorOrdersPanelGeneric({ successMessage: "Order cancelled successfully", errorMessage: "Failed to cancel order", successCallback: useCallback(() => { - executionsQuery.refetch(); - if (filters.orderTypes.includes("sow")) { - sowOrdersQuery.refetch(); - } - if (filters.orderTypes.includes("convertUp")) { - convertUpOrdersQuery.refetch(); - } - }, [executionsQuery.refetch, sowOrdersQuery.refetch, convertUpOrdersQuery.refetch, filters.orderTypes]), + // Invalidate tractor-related queries to refresh order data + queryClient.invalidateQueries({ queryKey: queryKeys.base.tractor }); + }, [queryClient]), }); const handleCancelOrder = async (order: UnifiedTractorOrder, e: React.MouseEvent) => { diff --git a/src/components/Tractor/form/SowOrderV0Fields.tsx b/src/components/Tractor/form/SowOrderV0Fields.tsx index f993012a..0d6e7d64 100644 --- a/src/components/Tractor/form/SowOrderV0Fields.tsx +++ b/src/components/Tractor/form/SowOrderV0Fields.tsx @@ -573,7 +573,10 @@ SowOrderV0Fields.PodDisplay = function PodDisplay({ )} - +
Referral Code
diff --git a/src/components/Tractor/form/SowOrderV0Schema.ts b/src/components/Tractor/form/SowOrderV0Schema.ts index 633b521f..b8a4c746 100644 --- a/src/components/Tractor/form/SowOrderV0Schema.ts +++ b/src/components/Tractor/form/SowOrderV0Schema.ts @@ -235,6 +235,7 @@ export type SowV0FormOrderData = { morningAuction: boolean; tokenStrategy: TractorTokenStrategy["type"]; token: Token | undefined; + referralCode?: string; }; export type SowOrderV0State = { @@ -322,6 +323,7 @@ export const useSowOrderV0State = () => { tokenStrategy: formData.selectedTokenStrategy.type, token: tokenInstance, operatorTip: formData.operatorTip || "", + referralCode: formData.referralCode || "", }); setState({ diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx index 83702342..89e5004a 100644 --- a/src/components/ui/Popover.tsx +++ b/src/components/ui/Popover.tsx @@ -11,9 +11,9 @@ const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { container?: HTMLElement | null } +>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => ( + Date: Wed, 21 Jan 2026 01:29:07 +0300 Subject: [PATCH 5/5] feat: improve sow action display and refine input component behavior --- src/components/ComboInputField.tsx | 10 +++++++--- src/components/SmartSubmitButton.tsx | 2 +- src/pages/field/actions/Sow.tsx | 22 +++++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/components/ComboInputField.tsx b/src/components/ComboInputField.tsx index a3613517..dfdc9f27 100644 --- a/src/components/ComboInputField.tsx +++ b/src/components/ComboInputField.tsx @@ -10,7 +10,7 @@ import { useFarmerSilo } from "@/state/useFarmerSilo"; import { usePriceData } from "@/state/usePriceData"; import useTokenData from "@/state/useTokenData"; import { formatter, truncateHex } from "@/utils/format"; -import { sanitizeNumericInputValue, stringEq, stringToNumber, toValidStringNumInput } from "@/utils/string"; +import { MAX_INPUT_VALUE, sanitizeNumericInputValue, stringEq, stringToNumber } from "@/utils/string"; import { FarmFromMode, Plot, Token } from "@/utils/types"; import { useDebouncedEffect } from "@/utils/useDebounce"; import { cn } from "@/utils/utils"; @@ -102,6 +102,9 @@ export interface ComboInputProps extends InputHTMLAttributes { enableSlider?: boolean; sliderMarkers?: number[]; customTokenSelector?: React.ReactNode; + + // Additional info display + showAdditionalInfo?: boolean; } function ComboInputField({ @@ -142,6 +145,7 @@ function ComboInputField({ placeholder, enableSlider, sliderMarkers, + showAdditionalInfo = true, }: ComboInputProps) { const tokenData = useTokenData(); const { balances } = useFarmerBalances(); @@ -453,7 +457,7 @@ function ComboInputField({ setIsUserInput(true); // Sanitize the input value - const cleaned = sanitizeNumericInputValue(value, getDecimals()); + const cleaned = sanitizeNumericInputValue(value, getDecimals(), false, MAX_INPUT_VALUE); const clamped = getClamped(cleaned.tv, maxAmount); // Set the display value to the sanitized string value @@ -631,7 +635,7 @@ function ComboInputField({ {formatter.usd(inputValue)} ) : null} - {mode === "deposits" && shouldShowAdditionalInfo() && ( + {mode === "deposits" && shouldShowAdditionalInfo() && showAdditionalInfo && ( <> Stalk diff --git a/src/components/SmartSubmitButton.tsx b/src/components/SmartSubmitButton.tsx index 0f827599..9d65336b 100644 --- a/src/components/SmartSubmitButton.tsx +++ b/src/components/SmartSubmitButton.tsx @@ -168,7 +168,7 @@ export default function SmartSubmitButton({ } const btnDisabled = disabled || allowanceFetching || submittingApproval || isConfirmingApproval; - const btnApproval = needsApproval && !allowanceFetching; + const btnApproval = needsApproval && !allowanceFetching && !disabled; return (
- +
{fromSilo ? ( <>