diff --git a/core/src/main/java/haveno/core/util/MoneroUriUtils.java b/core/src/main/java/haveno/core/util/MoneroUriUtils.java
new file mode 100644
index 0000000000..4116e919d4
--- /dev/null
+++ b/core/src/main/java/haveno/core/util/MoneroUriUtils.java
@@ -0,0 +1,164 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq 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.
+ *
+ * Bisq 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 Bisq. If not, see .
+ */
+
+/*
+ * 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 .
+ */
+
+package haveno.core.util;
+
+import monero.wallet.model.MoneroDestination;
+import monero.wallet.model.MoneroTxConfig;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+public class MoneroUriUtils {
+
+ public static String makeUri(List destinations, String label) {
+ if (destinations == null || destinations.isEmpty()) {
+ throw new IllegalArgumentException("Destinations cannot be null or empty");
+ }
+
+ StringBuilder sb = new StringBuilder("monero:");
+
+ // Append addresses separated by semicolon
+ for (int i = 0; i < destinations.size(); i++) {
+ sb.append(destinations.get(i).getAddress());
+ if (i < destinations.size() - 1) {
+ sb.append(";");
+ }
+ }
+
+ boolean firstParam = true;
+
+ // Check if any amount is present
+ boolean hasAmounts = false;
+ for (MoneroDestination dest : destinations) {
+ if (dest.getAmount() != null) {
+ hasAmounts = true;
+ break;
+ }
+ }
+
+ if (hasAmounts) {
+ sb.append("?tx_amount=");
+ for (int i = 0; i < destinations.size(); i++) {
+ BigInteger amount = destinations.get(i).getAmount();
+ if (amount != null) {
+ String amountStr = new BigDecimal(amount).divide(new BigDecimal("1000000000000")).stripTrailingZeros().toPlainString();
+ if (!amountStr.contains(".")) amountStr += ".0";
+ sb.append(amountStr);
+ }
+ if (i < destinations.size() - 1) {
+ sb.append(";");
+ }
+ }
+ firstParam = false;
+ }
+
+ if (label != null && !label.isEmpty()) {
+ sb.append(firstParam ? "?" : "&");
+ sb.append("tx_description=").append(URLEncoder.encode(label, StandardCharsets.UTF_8));
+ }
+
+ return sb.toString();
+ }
+
+ public static MoneroTxConfig parseUri(String uriStr) {
+ if (uriStr == null || !uriStr.startsWith("monero:")) {
+ throw new IllegalArgumentException("Invalid Monero URI");
+ }
+
+ String content = uriStr.substring("monero:".length());
+ String path;
+ String query = null;
+
+ int queryIndex = content.indexOf('?');
+ if (queryIndex != -1) {
+ path = content.substring(0, queryIndex);
+ query = content.substring(queryIndex + 1);
+ } else {
+ path = content;
+ }
+
+ String[] addresses = path.split(";");
+ Map params = parseQuery(query);
+
+ List destinations = new ArrayList<>();
+ String[] amounts = params.getOrDefault("tx_amount", "").split(";");
+
+ for (int i = 0; i < addresses.length; i++) {
+ BigInteger atomicAmount = null;
+ if (i < amounts.length && !amounts[i].isEmpty()) {
+ try {
+ atomicAmount = new BigDecimal(amounts[i]).multiply(new BigDecimal("1000000000000")).toBigInteger();
+ } catch (Exception ignored) {}
+ }
+ destinations.add(new MoneroDestination(addresses[i], atomicAmount));
+ }
+
+ MoneroTxConfig config = new MoneroTxConfig().setDestinations(destinations);
+
+ // Extract note from potential label/description parameters
+ String note = params.get("tx_description");
+ if (note == null || note.isEmpty()) note = params.get("tx_note");
+ if (note == null || note.isEmpty()) note = params.get("recipient_name");
+ if (note == null || note.isEmpty()) note = params.get("label");
+
+ if (note != null && !note.isEmpty()) {
+ config.setNote(note);
+ }
+
+ return config;
+ }
+
+ private static Map parseQuery(String query) {
+ Map params = new HashMap<>();
+ if (query == null || query.isEmpty()) return params;
+
+ String[] pairs = query.split("&");
+ for (String pair : pairs) {
+ int idx = pair.indexOf("=");
+ if (idx != -1) {
+ String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
+ String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
+ params.put(key, value);
+ }
+ }
+ return params;
+ }
+}
diff --git a/core/src/test/java/haveno/core/util/BountyVerification.java b/core/src/test/java/haveno/core/util/BountyVerification.java
new file mode 100644
index 0000000000..6f9e9c4a33
--- /dev/null
+++ b/core/src/test/java/haveno/core/util/BountyVerification.java
@@ -0,0 +1,104 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq 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.
+ *
+ * Bisq 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 Bisq. If not, see .
+ */
+
+/*
+ * 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 .
+ */
+
+package haveno.core.util;
+
+import monero.wallet.model.MoneroDestination;
+import monero.wallet.model.MoneroTxConfig;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class BountyVerification {
+
+ @Test
+ public void verifyBountyImplementation() {
+ System.out.println("=== Monero Multi-Destination URI Bounty Verification ===\n");
+
+ // 1. Setup multiple destinations with special characters and different amounts
+ List originalDestinations = new ArrayList<>();
+ originalDestinations.add(new MoneroDestination("44AFFq5kSiGBo3SnoCmcQC9R9X1844vAbaE74EutH43AnuW98jod9iTAwAsWAByY2v7r6Y1844vAbaE74EutH43AnuW98jod", new BigInteger("1234567890123"))); // 1.234567890123 XMR (High precision)
+ originalDestinations.add(new MoneroDestination("888tNkS9pU7649sbs19JpS7eJEXfK7X9VvCjS8q63nU2y26H07f6n3vCjS8q63nU2y26H07f6n3vCjS8q63nU2y26H07", new BigInteger("750000000000"))); // 0.75 XMR
+ String label = "Haveno Donation & Support; [Test]"; // Testing encoding and semicolons
+
+ System.out.println("Original Destinations:");
+ for (MoneroDestination dest : originalDestinations) {
+ System.out.println(" - Address: " + dest.getAddress());
+ System.out.println(" Amount: " + dest.getAmount() + " atomic units");
+ }
+ System.out.println("Label: " + label + "\n");
+
+ // 2. Generate URI
+ String generatedUri = MoneroUriUtils.makeUri(originalDestinations, label);
+ System.out.println("Generated URI (Standard Compliant):");
+ System.out.println(generatedUri + "\n");
+
+ // 3. Parse URI
+ System.out.println("Parsing generated URI...");
+ MoneroTxConfig parsedConfig = MoneroUriUtils.parseUri(generatedUri);
+ List parsedDestinations = parsedConfig.getDestinations();
+
+ System.out.println("\nParsed Results:");
+ boolean allMatch = true;
+ if (parsedDestinations.size() != originalDestinations.size()) {
+ System.err.println("Error: Destination count mismatch!");
+ allMatch = false;
+ }
+
+ for (int i = 0; i < parsedDestinations.size(); i++) {
+ MoneroDestination orig = originalDestinations.get(i);
+ MoneroDestination parsed = parsedDestinations.get(i);
+
+ System.out.println("Destination #" + (i + 1) + ":");
+ System.out.println(" Address Match: " + parsed.getAddress().equals(orig.getAddress()));
+ System.out.println(" Amount Match: " + parsed.getAmount().equals(orig.getAmount()) + " (" + parsed.getAmount() + ")");
+
+ if (!parsed.getAddress().equals(orig.getAddress()) || !parsed.getAmount().equals(orig.getAmount())) {
+ allMatch = false;
+ }
+ }
+
+ System.out.println("Label Match: " + parsedConfig.getNote().equals(label));
+ if (!parsedConfig.getNote().equals(label)) allMatch = false;
+
+ if (allMatch) {
+ System.out.println("\nVERIFICATION SUCCESSFUL: Implementation is correct and standard-compliant.");
+ } else {
+ System.err.println("\nVERIFICATION FAILED: Mismatch detected.");
+ System.exit(1);
+ }
+ }
+}
diff --git a/core/src/test/java/haveno/core/util/MoneroUriUtilsTest.java b/core/src/test/java/haveno/core/util/MoneroUriUtilsTest.java
new file mode 100644
index 0000000000..5e06c74fa1
--- /dev/null
+++ b/core/src/test/java/haveno/core/util/MoneroUriUtilsTest.java
@@ -0,0 +1,84 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq 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.
+ *
+ * Bisq 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 Bisq. If not, see .
+ */
+
+/*
+ * 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 .
+ */
+
+package haveno.core.util;
+
+import monero.wallet.model.MoneroDestination;
+import monero.wallet.model.MoneroTxConfig;
+import org.junit.jupiter.api.Test;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class MoneroUriUtilsTest {
+
+ @Test
+ public void testMakeUriSingle() {
+ List destinations = List.of(new MoneroDestination("addr1", new BigInteger("1000000000000")));
+ String uri = MoneroUriUtils.makeUri(destinations, "test");
+ assertEquals("monero:addr1?tx_amount=1.0&tx_description=test", uri);
+ }
+
+ @Test
+ public void testMakeUriMultiple() {
+ List destinations = new ArrayList<>();
+ destinations.add(new MoneroDestination("addr1", new BigInteger("1000000000000")));
+ destinations.add(new MoneroDestination("addr2", new BigInteger("500000000000")));
+ String uri = MoneroUriUtils.makeUri(destinations, "donations");
+ assertEquals("monero:addr1;addr2?tx_amount=1.0;0.5&tx_description=donations", uri);
+ }
+
+ @Test
+ public void testParseUriMultiple() {
+ String uri = "monero:addr1;addr2?tx_amount=1.0;0.5&tx_description=donations";
+ MoneroTxConfig config = MoneroUriUtils.parseUri(uri);
+ assertEquals(2, config.getDestinations().size());
+ assertEquals("addr1", config.getDestinations().get(0).getAddress());
+ assertEquals(new BigInteger("1000000000000"), config.getDestinations().get(0).getAmount());
+ assertEquals("addr2", config.getDestinations().get(1).getAddress());
+ assertEquals(new BigInteger("500000000000"), config.getDestinations().get(1).getAmount());
+ assertEquals("donations", config.getNote());
+ }
+
+ @Test
+ public void testParseUriWithNoAmounts() {
+ String uri = "monero:addr1;addr2?tx_description=test";
+ MoneroTxConfig config = MoneroUriUtils.parseUri(uri);
+ assertEquals(2, config.getDestinations().size());
+ assertNull(config.getDestinations().get(0).getAmount());
+ assertNull(config.getDestinations().get(1).getAmount());
+ }
+}
diff --git a/daemon/src/main/java/haveno/daemon/grpc/GrpcWalletsService.java b/daemon/src/main/java/haveno/daemon/grpc/GrpcWalletsService.java
index fba4851b18..0999e20230 100644
--- a/daemon/src/main/java/haveno/daemon/grpc/GrpcWalletsService.java
+++ b/daemon/src/main/java/haveno/daemon/grpc/GrpcWalletsService.java
@@ -73,7 +73,12 @@ import haveno.proto.grpc.UnlockWalletReply;
import haveno.proto.grpc.UnlockWalletRequest;
import haveno.proto.grpc.GetWalletHeightRequest;
import haveno.proto.grpc.GetWalletHeightReply;
+import haveno.proto.grpc.MakeMoneroUriReply;
+import haveno.proto.grpc.MakeMoneroUriRequest;
+import haveno.proto.grpc.ParseMoneroUriReply;
+import haveno.proto.grpc.ParseMoneroUriRequest;
import haveno.proto.grpc.WalletsGrpc.WalletsImplBase;
+import haveno.core.util.MoneroUriUtils;
import static haveno.proto.grpc.WalletsGrpc.getGetAddressBalanceMethod;
import static haveno.proto.grpc.WalletsGrpc.getGetBalancesMethod;
import static haveno.proto.grpc.WalletsGrpc.getGetFundingAddressesMethod;
@@ -334,6 +339,41 @@ class GrpcWalletsService extends WalletsImplBase {
}
}
+ @Override
+ public void makeMoneroUri(MakeMoneroUriRequest req, StreamObserver responseObserver) {
+ try {
+ List destinations = req.getDestinationsList().stream()
+ .map(d -> new MoneroDestination(d.getAddress(), d.getAmount().isEmpty() ? null : new BigInteger(d.getAmount())))
+ .collect(Collectors.toList());
+ String uri = MoneroUriUtils.makeUri(destinations, req.getLabel());
+ var reply = MakeMoneroUriReply.newBuilder().setUri(uri).build();
+ responseObserver.onNext(reply);
+ responseObserver.onCompleted();
+ } catch (Throwable cause) {
+ exceptionHandler.handleException(log, cause, responseObserver);
+ }
+ }
+
+ @Override
+ public void parseMoneroUri(ParseMoneroUriRequest req, StreamObserver responseObserver) {
+ try {
+ var txConfig = MoneroUriUtils.parseUri(req.getUri());
+ var reply = ParseMoneroUriReply.newBuilder()
+ .addAllDestinations(txConfig.getDestinations().stream()
+ .map(d -> haveno.proto.grpc.XmrDestination.newBuilder()
+ .setAddress(d.getAddress())
+ .setAmount(d.getAmount() == null ? "" : d.getAmount().toString())
+ .build())
+ .collect(Collectors.toList()))
+ .setLabel(txConfig.getNote() == null ? "" : txConfig.getNote())
+ .build();
+ responseObserver.onNext(reply);
+ responseObserver.onCompleted();
+ } catch (Throwable cause) {
+ exceptionHandler.handleException(log, cause, responseObserver);
+ }
+ }
+
final ServerInterceptor[] interceptors() {
Optional rateMeteringInterceptor = rateMeteringInterceptor();
return rateMeteringInterceptor.map(serverInterceptor ->
diff --git a/desktop/src/main/java/haveno/desktop/components/paymentmethods/AssetsForm.java b/desktop/src/main/java/haveno/desktop/components/paymentmethods/AssetsForm.java
index 0548c5d4b6..5dae1ada41 100644
--- a/desktop/src/main/java/haveno/desktop/components/paymentmethods/AssetsForm.java
+++ b/desktop/src/main/java/haveno/desktop/components/paymentmethods/AssetsForm.java
@@ -116,8 +116,15 @@ public class AssetsForm extends PaymentMethodForm {
addressInputTextField.textProperty().addListener((ov, oldValue, newValue) -> {
if (newValue.startsWith("monero:")) {
UserThread.execute(() -> {
- String addressWithoutPrefix = newValue.replace("monero:", "");
- addressInputTextField.setText(addressWithoutPrefix);
+ try {
+ monero.wallet.model.MoneroTxConfig config = haveno.core.util.MoneroUriUtils.parseUri(newValue);
+ if (!config.getDestinations().isEmpty()) {
+ addressInputTextField.setText(config.getDestinations().get(0).getAddress());
+ }
+ } catch (Exception e) {
+ String addressWithoutPrefix = newValue.replace("monero:", "");
+ addressInputTextField.setText(addressWithoutPrefix);
+ }
});
return;
}
diff --git a/desktop/src/main/java/haveno/desktop/util/GUIUtil.java b/desktop/src/main/java/haveno/desktop/util/GUIUtil.java
index f183f5b206..c3bdf693fd 100644
--- a/desktop/src/main/java/haveno/desktop/util/GUIUtil.java
+++ b/desktop/src/main/java/haveno/desktop/util/GUIUtil.java
@@ -55,8 +55,10 @@ import haveno.core.user.DontShowAgainLookup;
import haveno.core.user.Preferences;
import haveno.core.user.User;
import haveno.core.util.FormattingUtils;
+import haveno.core.util.MoneroUriUtils;
import haveno.core.util.coin.CoinFormatter;
import haveno.core.xmr.wallet.XmrWalletService;
+import monero.wallet.model.MoneroDestination;
import haveno.desktop.Navigation;
import haveno.desktop.components.AutoTooltipLabel;
import haveno.desktop.components.HavenoTextArea;
@@ -110,9 +112,7 @@ import javafx.stage.StageStyle;
import javafx.util.Callback;
import javafx.util.StringConverter;
import lombok.extern.slf4j.Slf4j;
-import monero.common.MoneroUtils;
import monero.daemon.model.MoneroTx;
-import monero.wallet.model.MoneroTxConfig;
import org.apache.commons.lang3.StringUtils;
import org.bitcoinj.core.Coin;
import org.jetbrains.annotations.NotNull;
@@ -765,10 +765,14 @@ public class GUIUtil {
}
public static String getMoneroURI(String address, BigInteger amount, String label) {
- MoneroTxConfig txConfig = new MoneroTxConfig().setAddress(address);
- if (amount != null) txConfig.setAmount(amount);
- if (label != null && !label.isEmpty() && !disablePaymentUriLabel) txConfig.setNote(label);
- return MoneroUtils.getPaymentUri(txConfig);
+ List destinations = List.of(new MoneroDestination(address, amount));
+ String finalLabel = (label != null && !label.isEmpty() && !disablePaymentUriLabel) ? label : null;
+ return MoneroUriUtils.makeUri(destinations, finalLabel);
+ }
+
+ public static String getMoneroURI(List destinations, String label) {
+ String finalLabel = (label != null && !label.isEmpty() && !disablePaymentUriLabel) ? label : null;
+ return MoneroUriUtils.makeUri(destinations, finalLabel);
}
public static boolean isBootstrappedOrShowPopup(P2PService p2PService) {
diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto
index cc8c1f00f6..fb3692d8fd 100644
--- a/proto/src/main/proto/grpc.proto
+++ b/proto/src/main/proto/grpc.proto
@@ -1002,6 +1002,10 @@ service Wallets {
}
rpc GetHeight (GetWalletHeightRequest) returns (GetWalletHeightReply) {
}
+ rpc MakeMoneroUri (MakeMoneroUriRequest) returns (MakeMoneroUriReply) {
+ }
+ rpc ParseMoneroUri (ParseMoneroUriRequest) returns (ParseMoneroUriReply) {
+ }
}
message GetBalancesRequest {
@@ -1184,3 +1188,21 @@ message GetWalletHeightReply {
int64 height = 1;
int64 target_height = 2;
}
+
+message MakeMoneroUriRequest {
+ repeated XmrDestination destinations = 1;
+ string label = 2;
+}
+
+message MakeMoneroUriReply {
+ string uri = 1;
+}
+
+message ParseMoneroUriRequest {
+ string uri = 1;
+}
+
+message ParseMoneroUriReply {
+ repeated XmrDestination destinations = 1;
+ string label = 2;
+}