diff --git a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java new file mode 100644 index 0000000..6ee9bc0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java @@ -0,0 +1,377 @@ +package com.sparrowwallet.drongo.uri; + +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; + +import java.math.BigDecimal; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.*; + +import static com.sparrowwallet.drongo.protocol.Transaction.*; + +/** + *

Provides a standard implementation of a Bitcoin URI with support for the following:

+ * + * + * + *

Accepted formats

+ * + *

The following input forms are accepted:

+ * + * + * + *

The name/value pairs are processed as follows.

+ *
    + *
  1. URL encoding is stripped and treated as UTF-8
  2. + *
  3. names prefixed with {@code req-} are treated as required and if unknown or conflicting cause a parse exception
  4. + *
  5. Unknown names not prefixed with {@code req-} are added to a Map, accessible by parameter name
  6. + *
  7. Known names not prefixed with {@code req-} are processed unless they are malformed
  8. + *
+ * + *

The following names are known and have the following formats:

+ * + * + * @author Andreas Schildbach (initial code) + * @author Jim Burton (enhancements for MultiBit) + * @author Gary Rowe (BIP21 support) + * @see BIP 0021 + */ +public class BitcoinURI { + public static final String FIELD_MESSAGE = "message"; + public static final String FIELD_LABEL = "label"; + public static final String FIELD_AMOUNT = "amount"; + public static final String FIELD_ADDRESS = "address"; + public static final String FIELD_PAYMENT_REQUEST_URL = "r"; + + public static final String BITCOIN_SCHEME = "bitcoin"; + private static final String ENCODED_SPACE_CHARACTER = "%20"; + private static final String AMPERSAND_SEPARATOR = "&"; + private static final String QUESTION_MARK_SEPARATOR = "?"; + + public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); + public static final int SMALLEST_UNIT_EXPONENT = 8; + + /** + * Contains all the parameters in the order in which they were processed + */ + private final Map parameterMap = new LinkedHashMap<>(); + + /** + * Constructs a new BitcoinURI from the given string. + * + * @param input The raw URI data to be parsed (see class comments for accepted formats) + * @throws BitcoinURIParseException if the URI is not syntactically or semantically valid. + */ + public BitcoinURI(String input) throws BitcoinURIParseException { + String scheme = BITCOIN_SCHEME; + + // Attempt to form the URI (fail fast syntax checking to official standards). + URI uri; + try { + uri = new URI(input); + } catch(URISyntaxException e) { + throw new BitcoinURIParseException("Bad URI syntax", e); + } + + // URI is formed as bitcoin:
? + // blockchain.info generates URIs of non-BIP compliant form bitcoin://address?.... + + // Remove the bitcoin scheme. + // (Note: getSchemeSpecificPart() is not used as it unescapes the label and parse then fails. + // For instance with : bitcoin:129mVqKUmJ9uwPxKJBnNdABbuaaNfho4Ha?amount=0.06&label=Tom%20%26%20Jerry + // the & (%26) in Tom and Jerry gets interpreted as a separator and the label then gets parsed + // as 'Tom ' instead of 'Tom & Jerry') + String blockchainInfoScheme = scheme + "://"; + String correctScheme = scheme + ":"; + String schemeSpecificPart; + final String inputLc = input.toLowerCase(Locale.US); + if(inputLc.startsWith(blockchainInfoScheme)) { + schemeSpecificPart = input.substring(blockchainInfoScheme.length()); + } else if(inputLc.startsWith(correctScheme)) { + schemeSpecificPart = input.substring(correctScheme.length()); + } else { + throw new BitcoinURIParseException("Unsupported URI scheme: " + uri.getScheme()); + } + + // Split off the address from the rest of the query parameters. + String[] addressSplitTokens = schemeSpecificPart.split("\\?", 2); + if(addressSplitTokens.length == 0) { + throw new BitcoinURIParseException("No data found after the bitcoin: prefix"); + } + String addressToken = addressSplitTokens[0]; // may be empty! + + String[] nameValuePairTokens; + if(addressSplitTokens.length == 1) { + // Only an address is specified - use an empty '=' token array. + nameValuePairTokens = new String[]{}; + } else { + // Split into '=' tokens. + nameValuePairTokens = addressSplitTokens[1].split("&"); + } + + // Attempt to parse the rest of the URI parameters. + parseParameters(addressToken, nameValuePairTokens); + + if(!addressToken.isEmpty()) { + // Attempt to parse the addressToken as a Bitcoin address for this network + try { + Address address = Address.fromString(addressToken); + putWithValidation(FIELD_ADDRESS, address); + } catch(final InvalidAddressException e) { + throw new BitcoinURIParseException("Invalid address", e); + } + } + + if(addressToken.isEmpty() && getPaymentRequestUrl() == null) { + throw new BitcoinURIParseException("No address and no r= parameter found"); + } + } + + /** + * @param nameValuePairTokens The tokens representing the name value pairs (assumed to be + * separated by '=' e.g. 'amount=0.2') + */ + private void parseParameters(String addressToken, String[] nameValuePairTokens) throws BitcoinURIParseException { + // Attempt to decode the rest of the tokens into a parameter map. + for(String nameValuePairToken : nameValuePairTokens) { + final int sepIndex = nameValuePairToken.indexOf('='); + if(sepIndex == -1) { + throw new BitcoinURIParseException("Malformed Bitcoin URI - no separator in '" + nameValuePairToken + "'"); + } + if(sepIndex == 0) { + throw new BitcoinURIParseException("Malformed Bitcoin URI - empty name '" + nameValuePairToken + "'"); + } + final String nameToken = nameValuePairToken.substring(0, sepIndex).toLowerCase(Locale.ENGLISH); + final String valueToken = nameValuePairToken.substring(sepIndex + 1); + + // Parse the amount. + if(FIELD_AMOUNT.equals(nameToken) && !valueToken.isEmpty()) { + // Decode the amount (contains an optional decimal component to 8dp). + try { + long amount = new BigDecimal(valueToken).movePointRight(SMALLEST_UNIT_EXPONENT).longValueExact(); + if(amount > MAX_BITCOIN * SATOSHIS_PER_BITCOIN) { + throw new BitcoinURIParseException("Maximum amount exceeded"); + } + if(amount < 0) { + throw new ArithmeticException("Negative amount specified"); + } + putWithValidation(FIELD_AMOUNT, amount); + } catch(IllegalArgumentException e) { + throw new OptionalFieldValidationException(String.format(Locale.US, "'%s' is not a valid amount", valueToken), e); + } catch(ArithmeticException e) { + throw new OptionalFieldValidationException(String.format(Locale.US, "'%s' has too many decimal places", valueToken), e); + } + } else { + if(nameToken.startsWith("req-")) { + // A required parameter that we do not know about. + throw new RequiredFieldValidationException("'" + nameToken + "' is required but not known, this URI is not valid"); + } else { + // Known fields and unknown parameters that are optional. + if(valueToken.length() > 0) { + putWithValidation(nameToken, URLDecoder.decode(valueToken, StandardCharsets.UTF_8)); + } + } + } + } + + // Note to the future: when you want to implement 'req-expires' have a look at commit 410a53791841 + // which had it in. + } + + /** + * Put the value against the key in the map checking for duplication. This avoids address field overwrite etc. + * + * @param key The key for the map + * @param value The value to store + */ + private void putWithValidation(String key, Object value) throws BitcoinURIParseException { + if(parameterMap.containsKey(key)) { + throw new BitcoinURIParseException(String.format(Locale.US, "'%s' is duplicated, URI is invalid", key)); + } else { + parameterMap.put(key, value); + } + } + + /** + * The Bitcoin address from the URI, if one was present. It's possible to have Bitcoin URI's with no address if a + * r= payment protocol parameter is specified, though this form is not recommended as older wallets can't understand + * it. + */ + public Address getAddress() { + return (Address)parameterMap.get(FIELD_ADDRESS); + } + + /** + * @return The amount name encoded using a pure integer value based at + * 10,000,000 units is 1 BTC. May be null if no amount is specified + */ + public Long getAmount() { + return (Long)parameterMap.get(FIELD_AMOUNT); + } + + /** + * @return The label from the URI. + */ + public String getLabel() { + return (String)parameterMap.get(FIELD_LABEL); + } + + /** + * @return The message from the URI. + */ + public String getMessage() { + return (String)parameterMap.get(FIELD_MESSAGE); + } + + /** + * @return The URL where a payment request (as specified in BIP 70) may + * be fetched. + */ + public final String getPaymentRequestUrl() { + return (String)parameterMap.get(FIELD_PAYMENT_REQUEST_URL); + } + + /** + * Returns the URLs where a payment request (as specified in BIP 70) may be fetched. The first URL is the main URL, + * all subsequent URLs are fallbacks. + */ + public List getPaymentRequestUrls() { + ArrayList urls = new ArrayList<>(); + while(true) { + int i = urls.size(); + String paramName = FIELD_PAYMENT_REQUEST_URL + (i > 0 ? Integer.toString(i) : ""); + String url = (String)parameterMap.get(paramName); + if(url == null) { + break; + } + urls.add(url); + } + Collections.reverse(urls); + return urls; + } + + /** + * @param name The name of the parameter + * @return The parameter value, or null if not present + */ + public Object getParameterByName(String name) { + return parameterMap.get(name); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("BitcoinURI["); + boolean first = true; + for(Map.Entry entry : parameterMap.entrySet()) { + if(first) { + first = false; + } else { + builder.append(","); + } + builder.append("'").append(entry.getKey()).append("'=").append("'").append(entry.getValue()).append("'"); + } + builder.append("]"); + return builder.toString(); + } + + /** + * Constructs a new BitcoinURI from the given address. + * + * @param address The address forming the base of the URI + */ + public static BitcoinURI fromAddress(Address address) { + try { + return new BitcoinURI(BITCOIN_SCHEME + ":" + address.toString()); + } catch(BitcoinURIParseException e) { + //Can't happen + return null; + } + } + + /** + * Simple Bitcoin URI builder using known good fields. + * + * @param address The Bitcoin address + * @param amount The amount + * @param label A label + * @param message A message + * @return A String containing the Bitcoin URI + */ + public static String convertToBitcoinURI(Address address, Long amount, String label, String message) { + return convertToBitcoinURI(address.toString(), amount, label, message); + } + + /** + * Simple Bitcoin URI builder using known good fields. + * + * @param address The Bitcoin address + * @param amount The amount + * @param label A label + * @param message A message + * @return A String containing the Bitcoin URI + */ + public static String convertToBitcoinURI(String address, Long amount, String label, String message) { + if(amount != null && amount < 0) { + throw new IllegalArgumentException("Amount must be positive"); + } + + StringBuilder builder = new StringBuilder(); + builder.append(BITCOIN_SCHEME).append(":").append(address); + + boolean questionMarkHasBeenOutput = false; + + if(amount != null) { + builder.append(QUESTION_MARK_SEPARATOR).append(FIELD_AMOUNT).append("="); + BTC_FORMAT.setMaximumFractionDigits(8); + builder.append(BTC_FORMAT.format(amount.doubleValue() / SATOSHIS_PER_BITCOIN)); + questionMarkHasBeenOutput = true; + } + + if(label != null && !label.isEmpty()) { + if(questionMarkHasBeenOutput) { + builder.append(AMPERSAND_SEPARATOR); + } else { + builder.append(QUESTION_MARK_SEPARATOR); + questionMarkHasBeenOutput = true; + } + builder.append(FIELD_LABEL).append("=").append(encodeURLString(label)); + } + + if(message != null && !message.isEmpty()) { + if(questionMarkHasBeenOutput) { + builder.append(AMPERSAND_SEPARATOR); + } else { + builder.append(QUESTION_MARK_SEPARATOR); + } + builder.append(FIELD_MESSAGE).append("=").append(encodeURLString(message)); + } + + return builder.toString(); + } + + /** + * Encode a string using URL encoding + * + * @param stringToEncode The string to URL encode + */ + static String encodeURLString(String stringToEncode) { + return java.net.URLEncoder.encode(stringToEncode, StandardCharsets.UTF_8).replace("+", ENCODED_SPACE_CHARACTER); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURIParseException.java b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURIParseException.java new file mode 100644 index 0000000..74f154c --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURIParseException.java @@ -0,0 +1,20 @@ +package com.sparrowwallet.drongo.uri; + +/** + *

Exception to provide the following to {@link BitcoinURI}:

+ *
    + *
  • Provision of parsing error messages
  • + *
+ *

This base exception acts as a general failure mode not attributable to a specific cause (other than + * that reported in the exception message). Since this is in English, it may not be worth reporting directly + * to the user other than as part of a "general failure to parse" response.

+ */ +public class BitcoinURIParseException extends Exception { + public BitcoinURIParseException(String s) { + super(s); + } + + public BitcoinURIParseException(String s, Throwable throwable) { + super(s, throwable); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/uri/OptionalFieldValidationException.java b/src/main/java/com/sparrowwallet/drongo/uri/OptionalFieldValidationException.java new file mode 100644 index 0000000..f5d2437 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/uri/OptionalFieldValidationException.java @@ -0,0 +1,23 @@ +package com.sparrowwallet.drongo.uri; + +/** + *

Exception to provide the following to {@link BitcoinURI}:

+ *
    + *
  • Provision of parsing error messages
  • + *
+ *

This exception occurs when an optional field is detected (under the Bitcoin URI scheme) and fails + * to pass the associated test (such as {@code amount} not being a valid number).

+ * + * @since 0.3.0 + * + */ +public class OptionalFieldValidationException extends BitcoinURIParseException { + + public OptionalFieldValidationException(String s) { + super(s); + } + + public OptionalFieldValidationException(String s, Throwable throwable) { + super(s, throwable); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/uri/RequiredFieldValidationException.java b/src/main/java/com/sparrowwallet/drongo/uri/RequiredFieldValidationException.java new file mode 100644 index 0000000..73131ac --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/uri/RequiredFieldValidationException.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.drongo.uri; + +/** + *

Exception to provide the following to {@link BitcoinURI}:

+ *
    + *
  • Provision of parsing error messages
  • + *
+ *

This exception occurs when a required field is detected (under the BIP21 rules) and fails + * to pass the associated test (such as {@code req-expires} being out of date), or the required field is unknown + * to this version of the client in which case it should fail for security reasons.

+ * + * @since 0.3.0 + * + */ +public class RequiredFieldValidationException extends BitcoinURIParseException { + + public RequiredFieldValidationException(String s) { + super(s); + } + + public RequiredFieldValidationException(String s, Throwable throwable) { + super(s, throwable); + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index cbe6610..41f8136 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -11,4 +11,5 @@ open module com.sparrowwallet.drongo { exports com.sparrowwallet.drongo.crypto; exports com.sparrowwallet.drongo.wallet; exports com.sparrowwallet.drongo.policy; + exports com.sparrowwallet.drongo.uri; } \ No newline at end of file