This commit is contained in:
SlowBearDigger 2026-01-04 23:08:55 +00:00 committed by GitHub
commit 8dce2e1ea4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 433 additions and 8 deletions

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
/*
* 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.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<MoneroDestination> 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<String, String> params = parseQuery(query);
List<MoneroDestination> 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<String, String> parseQuery(String query) {
Map<String, String> 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;
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
/*
* 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.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<MoneroDestination> 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<MoneroDestination> 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);
}
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
/*
* 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.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<MoneroDestination> 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<MoneroDestination> 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());
}
}

View file

@ -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<MakeMoneroUriReply> responseObserver) {
try {
List<MoneroDestination> 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<ParseMoneroUriReply> 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<ServerInterceptor> rateMeteringInterceptor = rateMeteringInterceptor();
return rateMeteringInterceptor.map(serverInterceptor ->

View file

@ -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;
}

View file

@ -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<MoneroDestination> 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<MoneroDestination> destinations, String label) {
String finalLabel = (label != null && !label.isEmpty() && !disablePaymentUriLabel) ? label : null;
return MoneroUriUtils.makeUri(destinations, finalLabel);
}
public static boolean isBootstrappedOrShowPopup(P2PService p2PService) {

View file

@ -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;
}