From 1a2dcfc704a5d5a90a6c155fe54a9dfc38d2d8b2 Mon Sep 17 00:00:00 2001
From: woodser <woodser@protonmail.com>
Date: Sat, 22 Mar 2025 07:57:31 -0400
Subject: [PATCH] limit offer extra info to 1500 characters

---
 .../haveno/core/offer/OpenOfferManager.java   |   8 +
 .../haveno/core/xmr/wallet/Restrictions.java  |   1 +
 .../resources/i18n/displayStrings.properties  |   1 +
 .../desktop/components/InputTextArea.java     | 140 ++++++++++++++++++
 .../src/main/java/haveno/desktop/haveno.css   |   6 +-
 .../desktop/main/offer/MutableOfferView.java  |  14 +-
 .../main/offer/MutableOfferViewModel.java     |  55 ++++---
 7 files changed, 195 insertions(+), 30 deletions(-)
 create mode 100644 desktop/src/main/java/haveno/desktop/components/InputTextArea.java

diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java
index 6ae03a7042..a08b767a47 100644
--- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java
+++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java
@@ -1396,6 +1396,14 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
                 return;
             }
 
+            // verify max length of extra info
+            if (offer.getOfferPayload().getExtraInfo() != null && offer.getOfferPayload().getExtraInfo().length() > Restrictions.MAX_EXTRA_INFO_LENGTH) {
+                errorMessage = "Extra info is too long for offer " + request.offerId + ". Max length is " + Restrictions.MAX_EXTRA_INFO_LENGTH + " but got " + offer.getOfferPayload().getExtraInfo().length();
+                log.warn(errorMessage);
+                sendAckMessage(request.getClass(), peer, request.getPubKeyRing(), request.getOfferId(), request.getUid(), false, errorMessage);
+                return;
+            }
+
             // verify the trade protocol version
             if (request.getOfferPayload().getProtocolVersion() != Version.TRADE_PROTOCOL_VERSION) {
                 errorMessage = "Unsupported protocol version: " + request.getOfferPayload().getProtocolVersion();
diff --git a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
index b270762d3b..aefb92c41a 100644
--- a/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
+++ b/core/src/main/java/haveno/core/xmr/wallet/Restrictions.java
@@ -30,6 +30,7 @@ public class Restrictions {
     public static final double MAX_SECURITY_DEPOSIT_PCT = 0.5;
     public static BigInteger MIN_TRADE_AMOUNT = HavenoUtils.xmrToAtomicUnits(0.1);
     public static BigInteger MIN_SECURITY_DEPOSIT = HavenoUtils.xmrToAtomicUnits(0.1);
+    public static int MAX_EXTRA_INFO_LENGTH = 1500;
 
     // At mediation we require a min. payout to the losing party to keep incentive for the trader to accept the
     // mediated payout. For Refund agent cases we do not have that restriction.
diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties
index cb6a632270..82a09919ba 100644
--- a/core/src/main/resources/i18n/displayStrings.properties
+++ b/core/src/main/resources/i18n/displayStrings.properties
@@ -495,6 +495,7 @@ createOffer.triggerPrice.tooltip=As protection against drastic price movements y
   deactivates the offer if the market price reaches that value.
 createOffer.triggerPrice.invalid.tooLow=Value must be higher than {0}
 createOffer.triggerPrice.invalid.tooHigh=Value must be lower than {0}
+createOffer.extraInfo.invalid.tooLong=Must not exceed {0} characters.
 
 # new entries
 createOffer.placeOfferButton=Review: Place offer to {0} monero
diff --git a/desktop/src/main/java/haveno/desktop/components/InputTextArea.java b/desktop/src/main/java/haveno/desktop/components/InputTextArea.java
new file mode 100644
index 0000000000..7bcd18de93
--- /dev/null
+++ b/desktop/src/main/java/haveno/desktop/components/InputTextArea.java
@@ -0,0 +1,140 @@
+/*
+ * This file is part of Haveno.
+ *
+ * Haveno is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Haveno is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Haveno. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package haveno.desktop.components;
+
+
+import com.jfoenix.controls.JFXTextArea;
+import haveno.core.util.validation.InputValidator;
+import haveno.desktop.util.validation.JFXInputValidator;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.scene.control.Skin;
+
+/**
+ * TextArea with validation support.
+ * If validator is set it supports on focus out validation with that validator. If a more sophisticated validation is
+ * needed the validationResultProperty can be used for applying validation result done by external validation.
+ * In case the isValid property in validationResultProperty get set to false we display a red border and an error
+ * message within the errorMessageDisplay placed on the right of the text area.
+ * The errorMessageDisplay gets closed when the ValidatingTextArea instance gets removed from the scene graph or when
+ * hideErrorMessageDisplay() is called.
+ * There can be only 1 errorMessageDisplays at a time we use static field for it.
+ * The position is derived from the position of the textArea itself or if set from the layoutReference node.
+ */
+//TODO There are some rare situation where it behaves buggy. Needs further investigation and improvements.
+public class InputTextArea extends JFXTextArea {
+
+    private final ObjectProperty<InputValidator.ValidationResult> validationResult = new SimpleObjectProperty<>
+            (new InputValidator.ValidationResult(true));
+
+    private final JFXInputValidator jfxValidationWrapper = new JFXInputValidator();
+
+    private InputValidator validator;
+    private String errorMessage = null;
+
+
+    public InputValidator getValidator() {
+        return validator;
+    }
+
+    public void setValidator(InputValidator validator) {
+        this.validator = validator;
+    }
+
+    public void setErrorMessage(String errorMessage) {
+        this.errorMessage = errorMessage;
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Constructor
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public InputTextArea() {
+        super();
+
+        getValidators().add(jfxValidationWrapper);
+
+        validationResult.addListener((ov, oldValue, newValue) -> {
+            if (newValue != null) {
+                jfxValidationWrapper.resetValidation();
+                if (!newValue.isValid) {
+                    if (!newValue.errorMessageEquals(oldValue)) {  // avoid blinking
+                        validate();  // ensure that the new error message replaces the old one
+                    }
+                    if (this.errorMessage != null) {
+                        jfxValidationWrapper.applyErrorMessage(this.errorMessage);
+                    } else {
+                        jfxValidationWrapper.applyErrorMessage(newValue);
+                    }
+                }
+                validate();
+            }
+        });
+
+        textProperty().addListener((o, oldValue, newValue) -> {
+            refreshValidation();
+        });
+
+        focusedProperty().addListener((o, oldValue, newValue) -> {
+            if (validator != null) {
+                if (!oldValue && newValue) {
+                    this.validationResult.set(new InputValidator.ValidationResult(true));
+                } else {
+                    this.validationResult.set(validator.validate(getText()));
+                }
+            }
+        });
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Public methods
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public void resetValidation() {
+        jfxValidationWrapper.resetValidation();
+
+        String input = getText();
+        if (input.isEmpty()) {
+            validationResult.set(new InputValidator.ValidationResult(true));
+        } else {
+            validationResult.set(validator.validate(input));
+        }
+    }
+
+    public void refreshValidation() {
+        if (validator != null) {
+            this.validationResult.set(validator.validate(getText()));
+        }
+    }
+
+    public void setInvalid(String message) {
+        validationResult.set(new InputValidator.ValidationResult(false, message));
+    }
+
+    ///////////////////////////////////////////////////////////////////////////////////////////
+    // Getters
+    ///////////////////////////////////////////////////////////////////////////////////////////
+
+    public ObjectProperty<InputValidator.ValidationResult> validationResultProperty() {
+        return validationResult;
+    }
+
+    protected Skin<?> createDefaultSkin() {
+        return new JFXTextAreaSkinHavenoStyle(this);
+    }
+}
diff --git a/desktop/src/main/java/haveno/desktop/haveno.css b/desktop/src/main/java/haveno/desktop/haveno.css
index fc92b1c308..2f50f8d0f3 100644
--- a/desktop/src/main/java/haveno/desktop/haveno.css
+++ b/desktop/src/main/java/haveno/desktop/haveno.css
@@ -501,15 +501,15 @@ tree-table-view:focused {
     -jfx-default-color: -bs-color-primary;
 }
 
-.jfx-date-picker .jfx-text-field {
+.jfx-date-picker .jfx-text-field .jfx-text-area {
     -fx-padding: 0.333333em 0em 0.333333em 0em;
 }
 
-.jfx-date-picker .jfx-text-field > .input-line {
+.jfx-date-picker .jfx-text-field .jfx-text-area > .input-line {
     -fx-translate-x: 0em;
 }
 
-.jfx-date-picker .jfx-text-field > .input-focused-line {
+.jfx-date-picker .jfx-text-field .jfx-text-area > .input-focused-line {
     -fx-translate-x: 0em;
 }
 
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
index e3f9132a84..1ed15ad845 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java
@@ -44,8 +44,8 @@ import haveno.desktop.components.AutoTooltipLabel;
 import haveno.desktop.components.BalanceTextField;
 import haveno.desktop.components.BusyAnimation;
 import haveno.desktop.components.FundsTextField;
-import haveno.desktop.components.HavenoTextArea;
 import haveno.desktop.components.InfoInputTextField;
+import haveno.desktop.components.InputTextArea;
 import haveno.desktop.components.InputTextField;
 import haveno.desktop.components.TitledGroupBg;
 import haveno.desktop.main.MainView;
@@ -76,7 +76,6 @@ import javafx.scene.control.ComboBox;
 import javafx.scene.control.Label;
 import javafx.scene.control.ScrollPane;
 import javafx.scene.control.Separator;
-import javafx.scene.control.TextArea;
 import javafx.scene.control.TextField;
 import javafx.scene.control.ToggleButton;
 import javafx.scene.control.Tooltip;
@@ -140,7 +139,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
     private BalanceTextField balanceTextField;
     private ToggleButton reserveExactAmountSlider;
     private ToggleButton buyerAsTakerWithoutDepositSlider;
-    protected TextArea extraInfoTextArea;
+    protected InputTextArea extraInfoTextArea;
     private FundsTextField totalToPayTextField;
     private Label amountDescriptionLabel, priceCurrencyLabel, priceDescriptionLabel, volumeDescriptionLabel,
             waitingForFundsLabel, marketBasedPriceLabel, percentagePriceDescriptionLabel, tradeFeeDescriptionLabel,
@@ -211,7 +210,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
 
         createListeners();
 
-        balanceTextField.setFormatter(model.getBtcFormatter());
+        balanceTextField.setFormatter(model.getXmrFormatter());
 
         paymentAccountsComboBox.setConverter(GUIUtil.getPaymentAccountsComboBoxStringConverter());
         paymentAccountsComboBox.setButtonCell(GUIUtil.getComboBoxButtonCell(Res.get("shared.chooseTradingAccount"),
@@ -592,6 +591,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
         triggerPriceInputTextField.validationResultProperty().bind(model.triggerPriceValidationResult);
         volumeTextField.validationResultProperty().bind(model.volumeValidationResult);
         securityDepositInputTextField.validationResultProperty().bind(model.securityDepositValidationResult);
+        extraInfoTextArea.validationResultProperty().bind(model.extraInfoValidationResult);
 
         // funding
         fundingHBox.visibleProperty().bind(model.getDataModel().getIsXmrWalletFunded().not().and(model.showPayFundsScreenDisplayed));
@@ -713,7 +713,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
             triggerPriceInputTextField.setText(model.triggerPrice.get());
         };
         extraInfoFocusedListener = (observable, oldValue, newValue) -> {
-            model.onFocusOutExtraInfoTextField(oldValue, newValue);
+            model.onFocusOutExtraInfoTextArea(oldValue, newValue);
             extraInfoTextArea.setText(model.extraInfo.get());
         };
 
@@ -1097,7 +1097,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
                 Res.get("payment.shared.optionalExtra"), 25 + heightAdjustment);
         GridPane.setColumnSpan(extraInfoTitledGroupBg, 3);
 
-        extraInfoTextArea = new HavenoTextArea();
+        extraInfoTextArea = new InputTextArea();
         extraInfoTextArea.setPromptText(Res.get("payment.shared.extraInfo.prompt.offer"));
         extraInfoTextArea.getStyleClass().add("text-area");
         extraInfoTextArea.setWrapText(true);
@@ -1109,7 +1109,7 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> exten
         GridPane.setColumnSpan(extraInfoTextArea, GridPane.REMAINING);
         GridPane.setColumnIndex(extraInfoTextArea, 0);
         GridPane.setHalignment(extraInfoTextArea, HPos.LEFT);
-        GridPane.setMargin(extraInfoTextArea, new Insets(Layout.COMPACT_FIRST_ROW_AND_GROUP_DISTANCE, 0, 0, 0));
+        GridPane.setMargin(extraInfoTextArea, new Insets(Layout.COMPACT_FIRST_ROW_AND_GROUP_DISTANCE, 0, 10, 0));
         gridPane.getChildren().add(extraInfoTextArea);
     }
 
diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
index e32869afe2..6d087b27ea 100644
--- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
+++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java
@@ -99,7 +99,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
     private final AccountAgeWitnessService accountAgeWitnessService;
     private final Navigation navigation;
     private final Preferences preferences;
-    protected final CoinFormatter btcFormatter;
+    protected final CoinFormatter xmrFormatter;
     private final FiatVolumeValidator fiatVolumeValidator;
     private final AmountValidator4Decimals amountValidator4Decimals;
     private final AmountValidator8Decimals amountValidator8Decimals;
@@ -160,6 +160,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
     final ObjectProperty<InputValidator.ValidationResult> triggerPriceValidationResult = new SimpleObjectProperty<>(new InputValidator.ValidationResult(true));
     final ObjectProperty<InputValidator.ValidationResult> volumeValidationResult = new SimpleObjectProperty<>();
     final ObjectProperty<InputValidator.ValidationResult> securityDepositValidationResult = new SimpleObjectProperty<>();
+    final ObjectProperty<InputValidator.ValidationResult> extraInfoValidationResult = new SimpleObjectProperty<>();
 
     private ChangeListener<String> amountStringListener;
     private ChangeListener<String> minAmountStringListener;
@@ -195,26 +196,26 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
                                  FiatVolumeValidator fiatVolumeValidator,
                                  AmountValidator4Decimals amountValidator4Decimals,
                                  AmountValidator8Decimals amountValidator8Decimals,
-                                 XmrValidator btcValidator,
+                                 XmrValidator xmrValidator,
                                  SecurityDepositValidator securityDepositValidator,
                                  PriceFeedService priceFeedService,
                                  AccountAgeWitnessService accountAgeWitnessService,
                                  Navigation navigation,
                                  Preferences preferences,
-                                 @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter,
+                                 @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter xmrFormatter,
                                  OfferUtil offerUtil) {
         super(dataModel);
 
         this.fiatVolumeValidator = fiatVolumeValidator;
         this.amountValidator4Decimals = amountValidator4Decimals;
         this.amountValidator8Decimals = amountValidator8Decimals;
-        this.xmrValidator = btcValidator;
+        this.xmrValidator = xmrValidator;
         this.securityDepositValidator = securityDepositValidator;
         this.priceFeedService = priceFeedService;
         this.accountAgeWitnessService = accountAgeWitnessService;
         this.navigation = navigation;
         this.preferences = preferences;
-        this.btcFormatter = btcFormatter;
+        this.xmrFormatter = xmrFormatter;
         this.offerUtil = offerUtil;
 
         paymentLabel = Res.get("createOffer.fundsBox.paymentLabel", dataModel.shortOfferId);
@@ -500,11 +501,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
         };
 
         extraInfoStringListener = (ov, oldValue, newValue) -> {
-            if (newValue != null) {
-                extraInfo.set(newValue);
-            } else {
-                extraInfo.set("");
-            }
+            onExtraInfoTextAreaChanged();
         };
 
         isWalletFundedListener = (ov, oldValue, newValue) -> updateButtonDisableState();
@@ -531,7 +528,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
         tradeFee.set(HavenoUtils.formatXmr(makerFee));
         tradeFeeInXmrWithFiat.set(OfferViewModelUtil.getTradeFeeWithFiatEquivalent(offerUtil,
                 dataModel.getMaxMakerFee(),
-                btcFormatter));
+                xmrFormatter));
     }
 
 
@@ -836,8 +833,16 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
         }
     }
 
-    public void onFocusOutExtraInfoTextField(boolean oldValue, boolean newValue) {
+    public void onFocusOutExtraInfoTextArea(boolean oldValue, boolean newValue) {
         if (oldValue && !newValue) {
+            onExtraInfoTextAreaChanged();
+        }
+    }
+
+    public void onExtraInfoTextAreaChanged() {
+        extraInfoValidationResult.set(getExtraInfoValidationResult());
+        updateButtonDisableState();
+        if (extraInfoValidationResult.get().isValid) {
             dataModel.setExtraInfo(extraInfo.get());
         }
     }
@@ -1045,8 +1050,8 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
                 .show();
     }
 
-    CoinFormatter getBtcFormatter() {
-        return btcFormatter;
+    CoinFormatter getXmrFormatter() {
+        return xmrFormatter;
     }
 
     public boolean isShownAsBuyOffer() {
@@ -1064,7 +1069,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
     public String getTradeAmount() {
         return OfferViewModelUtil.getTradeFeeWithFiatEquivalent(offerUtil,
                 dataModel.getAmount().get(),
-                btcFormatter);
+                xmrFormatter);
     }
 
     public String getSecurityDepositLabel() {
@@ -1084,7 +1089,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
         return OfferViewModelUtil.getTradeFeeWithFiatEquivalentAndPercentage(offerUtil,
                 dataModel.getSecurityDeposit(),
                 dataModel.getAmount().get(),
-                btcFormatter
+                xmrFormatter
         );
     }
 
@@ -1097,7 +1102,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
         return OfferViewModelUtil.getTradeFeeWithFiatEquivalentAndPercentage(offerUtil,
                 dataModel.getMaxMakerFee(),
                 dataModel.getAmount().get(),
-                btcFormatter);
+                xmrFormatter);
     }
 
     public String getMakerFeePercentage() {
@@ -1108,7 +1113,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
     public String getTotalToPayInfo() {
         return OfferViewModelUtil.getTradeFeeWithFiatEquivalent(offerUtil,
                 dataModel.totalToPay.get(),
-                btcFormatter);
+                xmrFormatter);
     }
 
     public String getFundsStructure() {
@@ -1181,7 +1186,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
 
     private void setAmountToModel() {
         if (amount.get() != null && !amount.get().isEmpty()) {
-            BigInteger amount = HavenoUtils.coinToAtomicUnits(DisplayUtils.parseToCoinWith4Decimals(this.amount.get(), btcFormatter));
+            BigInteger amount = HavenoUtils.coinToAtomicUnits(DisplayUtils.parseToCoinWith4Decimals(this.amount.get(), xmrFormatter));
 
             long maxTradeLimit = dataModel.getMaxTradeLimit();
             Price price = dataModel.getPrice().get();
@@ -1202,7 +1207,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
 
     private void setMinAmountToModel() {
         if (minAmount.get() != null && !minAmount.get().isEmpty()) {
-            BigInteger minAmount = HavenoUtils.coinToAtomicUnits(DisplayUtils.parseToCoinWith4Decimals(this.minAmount.get(), btcFormatter));
+            BigInteger minAmount = HavenoUtils.coinToAtomicUnits(DisplayUtils.parseToCoinWith4Decimals(this.minAmount.get(), xmrFormatter));
 
             Price price = dataModel.getPrice().get();
             long maxTradeLimit = dataModel.getMaxTradeLimit();
@@ -1343,10 +1348,20 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> ext
             inputDataValid = inputDataValid && securityDepositValidator.validate(securityDeposit.get()).isValid;
         }
 
+        inputDataValid = inputDataValid && getExtraInfoValidationResult().isValid;
+
         isNextButtonDisabled.set(!inputDataValid);
         isPlaceOfferButtonDisabled.set(createOfferRequested || !inputDataValid || !dataModel.getIsXmrWalletFunded().get());
     }
 
+    private ValidationResult getExtraInfoValidationResult() {
+        if (extraInfo.get() != null && !extraInfo.get().isEmpty() && extraInfo.get().length() > Restrictions.MAX_EXTRA_INFO_LENGTH) {
+            return new InputValidator.ValidationResult(false, Res.get("createOffer.extraInfo.invalid.tooLong", Restrictions.MAX_EXTRA_INFO_LENGTH));
+        } else {
+            return new InputValidator.ValidationResult(true);
+        }
+    }
+
     private void updateMarketPriceToManual() {
         final String currencyCode = dataModel.getTradeCurrencyCode().get();
         MarketPrice marketPrice = priceFeedService.getMarketPrice(currencyCode);