/** * Copyright 2012 multibit.org * * Licensed under the MIT license (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://opensource.org/licenses/mit-license.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.multibit.model.bitcoin; import com.google.bitcoin.core.Address; import com.google.bitcoin.core.ECKey; import com.google.bitcoin.core.Wallet; import org.multibit.MultiBit; import org.multibit.file.WalletLoadException; import org.multibit.file.WalletSaveException; import org.multibit.store.MultiBitWalletVersion; import org.multibit.store.WalletVersionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.util.*; /** * Wallet info is the companion info to the bitcoinj Wallet that multibit uses * it contains the sending and receiving addresses and the wallet version. * * It is stored in the same directory as the wallet and has the suffix ".info". * * @author jim * */ public class WalletInfoData { private static final Logger log = LoggerFactory.getLogger(WalletInfoData.class); private static final String ENCODED_SPACE_CHARACTER = "%20"; private static final String VALID_HEX_CHARACTERS = "01234567890abcdef"; /** * The actual receiving addresses exposed for this address book (only keys * that occur in this wallet). */ private ArrayList<WalletAddressBookData> receivingAddresses; private ArrayList<WalletAddressBookData> sendingAddresses; private static final String INFO_FILE_EXTENSION = "info"; private static final String RECEIVE_ADDRESS_MARKER = "receive"; private static final String SEND_ADDRESS_MARKER = "send"; private static final String PROPERTY_MARKER = "property"; private static final String SEPARATOR = ","; private static final String INFO_MAGIC_TEXT = "multiBit.info"; private static final String INFO_VERSION_TEXT = "1"; private static final String WALLET_VERSION_MARKER = "walletVersion"; public static final String DESCRIPTION_PROPERTY = "walletDescription"; public static final String SIZE_PROPERTY = "walletSize"; public static final String DATE_LAST_MODIFED_PROPERTY = "walletLastModified"; private String walletFilename; private MultiBitWalletVersion walletVersion; private Wallet wallet; private Properties walletPreferences; /** * Flag indicated that the wallet has been deleted and should not be used. */ private boolean deleted = false; /** * * @param walletFilename * the filename for the wallet this is the info for */ public WalletInfoData(String walletFilename, Wallet wallet, MultiBitWalletVersion walletVersion) { this.walletFilename = walletFilename; this.walletVersion = walletVersion; this.wallet = wallet; receivingAddresses = new ArrayList<WalletAddressBookData>(); sendingAddresses = new ArrayList<WalletAddressBookData>(); walletPreferences = new Properties(); try { loadFromFile(); } catch (WalletLoadException wle) { // load may fail on first construct if no backing file written - // just log it unless it is from wallet version log.debug(wle.getMessage()); if (wle.getCause() != null) { log.debug("Cause : " + wle.getCause().getMessage()); } } } /** * Put a String property into the walletinfo. * * @param key * @param value */ public void put(String key, String value) { walletPreferences.put(key, value); } /** * Get a String property from the wallet info. * * @param key * @return */ public String getProperty(String key) { return walletPreferences.getProperty(key); } /** * Removes a property from the wallet info. * * @param key */ public void remove(String key) { walletPreferences.remove(key); } public ArrayList<WalletAddressBookData> getReceivingAddresses() { return receivingAddresses; } public ArrayList<WalletAddressBookData> getSendingAddresses() { return sendingAddresses; } public void setReceivingAddresses(ArrayList<WalletAddressBookData> receivingAddresses) { this.receivingAddresses = receivingAddresses; } /** * Add a receiving address in the form of an WalletAddressBookData, * replacing the label of any existing address. * * @param receivingAddress * @param checkAlreadyPresent */ public void addReceivingAddress(WalletAddressBookData receivingAddress, boolean checkAlreadyPresent) { if (receivingAddress == null || receivingAddress.getAddress() == null) { return; } boolean justUpdateLabel = false; if (checkAlreadyPresent) { // Check the address is not already in the set. for (WalletAddressBookData addressBookData : receivingAddresses) { if (addressBookData.getAddress().equals(receivingAddress.getAddress())) { // Just update label. addressBookData.setLabel(receivingAddress.getLabel()); justUpdateLabel = true; break; } } } boolean addressMatchesKey = false; if (wallet != null) { for (ECKey key : wallet.getKeys()) { if (receivingAddress.getAddress().equals( key.toAddress(MultiBit.getBitcoinController().getModel().getNetworkParameters()).toString())) { addressMatchesKey = true; break; } } } if (!justUpdateLabel && (wallet == null || addressMatchesKey)) { receivingAddresses.add(receivingAddress); } } /** * Ensure only receiving addresses actually in the wallet appear. This is to * prevent adding receiving addresses manually in the info file. */ public void checkAllReceivingAddressesAppearInWallet(Wallet wallet) { List<WalletAddressBookData> toRemove = new ArrayList<WalletAddressBookData>(); if (wallet != null) { Iterator<WalletAddressBookData> iterator = receivingAddresses.iterator(); while (iterator.hasNext()) { boolean addressMatchesKey = false; WalletAddressBookData walletAddressBookData = iterator.next(); for (ECKey key : wallet.getKeys()) { if (walletAddressBookData.getAddress().equals( key.toAddress(MultiBit.getBitcoinController().getModel().getNetworkParameters()).toString())) { addressMatchesKey = true; break; } } if (!addressMatchesKey) { // Remove from receivingAddresses and log. toRemove.add(walletAddressBookData); log.debug("Removed receiving address " + walletAddressBookData.getAddress() + " because it did not match a key in the wallet '" + wallet.getDescription() + "'"); } } receivingAddresses.removeAll(toRemove); } } /** * Add a receiving address that belongs to a key of the current wallet this * will always be added and will take the label of any matching address in * the list of candidate addresses from the address book. * * @param receivingAddress */ public void addReceivingAddressOfKey(Address receivingAddress) { if (receivingAddress == null) { return; } if (!containsReceivingAddress(receivingAddress.toString())) { receivingAddresses.add(new WalletAddressBookData("", receivingAddress.toString())); } } public boolean containsReceivingAddress(String receivingAddress) { boolean toReturn = false; // see if the receiving address is on the current list for (WalletAddressBookData addressBookData : receivingAddresses) { if (addressBookData.getAddress().equals(receivingAddress.toString())) { // do nothing toReturn = true; break; } } return toReturn; } public void addSendingAddress(WalletAddressBookData sendingAddress) { if (sendingAddress == null) { return; } boolean done = false; // Check the address is not already in the arraylist. for (WalletAddressBookData addressBookData : sendingAddresses) { if (addressBookData.getAddress() != null && addressBookData.getAddress().equals(sendingAddress.getAddress())) { // Just update label. addressBookData.setLabel(sendingAddress.getLabel()); done = true; break; } } if (!done) { sendingAddresses.add(sendingAddress); } } public String lookupLabelForReceivingAddress(String address) { for (WalletAddressBookData addressBookData : receivingAddresses) { if (addressBookData.getAddress().equals(address)) { return addressBookData.getLabel(); } } return ""; } public String lookupLabelForSendingAddress(String address) { for (WalletAddressBookData addressBookData : sendingAddresses) { if (addressBookData.getAddress().equals(address)) { return addressBookData.getLabel(); } } return ""; } /** * Write out the wallet info to the file specified as a parameter - a comma * separated file format is used. * * @param walletInfoFilename * The full path of the wallet info file to write * @param walletVersion * The wallet version. * @throws WalletSaveException * Exception if write is unsuccessful */ public void writeToFile(String walletInfoFilename, MultiBitWalletVersion walletVersion) throws WalletSaveException { BufferedWriter out = null; try { // We write out all the receiving addresses. HashMap<String, WalletAddressBookData> allReceivingAddresses = new HashMap<String, WalletAddressBookData>(); if (receivingAddresses != null) { for (WalletAddressBookData addressBookData : receivingAddresses) { allReceivingAddresses.put(addressBookData.address, addressBookData); } } // Create file. out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(walletInfoFilename), "UTF8")); // Write out the multibit addressbook identifier. out.write(INFO_MAGIC_TEXT + SEPARATOR + INFO_VERSION_TEXT + "\n"); // Write out the wallet version. out.write(WALLET_VERSION_MARKER + SEPARATOR + walletVersion.getWalletVersionString() + "\n"); Collection<WalletAddressBookData> receiveAddressValues = allReceivingAddresses.values(); for (WalletAddressBookData addressBookData : receiveAddressValues) { String columnOne = RECEIVE_ADDRESS_MARKER; String columnTwo = addressBookData.getAddress(); String columnThree = addressBookData.getLabel(); if (columnTwo == null) { columnTwo = ""; } out.write(columnOne + SEPARATOR + columnTwo + SEPARATOR + encodeURLString(columnThree) + "\n"); } for (WalletAddressBookData addressBookData : sendingAddresses) { String columnOne = SEND_ADDRESS_MARKER; String columnTwo = addressBookData.getAddress(); String columnThree = addressBookData.getLabel(); if (columnTwo == null) { columnTwo = ""; } out.write(columnOne + SEPARATOR + columnTwo + SEPARATOR + encodeURLString(columnThree) + "\n"); } // Remove some properties form the wallet file that dont need to be persisted. Properties walletPreferencesClone = new Properties(); walletPreferencesClone.putAll(walletPreferences); walletPreferencesClone.remove(BitcoinModel.VALIDATION_ADDRESS_IS_INVALID); walletPreferencesClone.remove(BitcoinModel.VALIDATION_ADDRESS_VALUE); walletPreferencesClone.remove(BitcoinModel.VALIDATION_AMOUNT_IS_INVALID); walletPreferencesClone.remove(BitcoinModel.VALIDATION_AMOUNT_IS_MISSING); walletPreferencesClone.remove(BitcoinModel.VALIDATION_AMOUNT_IS_NEGATIVE_OR_ZERO); walletPreferencesClone.remove(BitcoinModel.VALIDATION_AMOUNT_VALUE); walletPreferencesClone.remove(BitcoinModel.VALIDATION_NOT_ENOUGH_FUNDS); walletPreferencesClone.remove(BitcoinModel.SEND_PERFORM_PASTE_NOW); // These properties are obselete so removed from the info file to tidy them up. walletPreferencesClone.remove("sendErrorMessage"); walletPreferencesClone.remove("sendWasSuccessful"); walletPreferencesClone.remove("earliestTransactionDate"); for (Map.Entry entry : walletPreferencesClone.entrySet()) { String columnOne = PROPERTY_MARKER; String columnTwo = (String) entry.getKey(); String encodedColumnThree = encodeURLString((String) entry.getValue()); if (columnTwo == null) { columnTwo = ""; } out.write(columnOne + SEPARATOR + columnTwo + SEPARATOR + encodedColumnThree + "\n"); } } catch (IOException ioe) { throw new WalletSaveException("Could not write walletinfo file for wallet '" + walletInfoFilename + "'", ioe); } finally { // Close the output stream. if (out != null) { try { out.close(); } catch (IOException e) { throw new WalletSaveException("Could not close walletinfo file for wallet '" + walletInfoFilename + "'", e); } } } } /** * Load the internally referenced wallet info file. */ public void loadFromFile() { String walletInfoFilename = null; InputStream inputStream = null; try { walletPreferences = new Properties(); // Read in the wallet info data. walletInfoFilename = createWalletInfoFilename(walletFilename); FileInputStream fileInputStream = new FileInputStream(walletInfoFilename); // Get the object of DataInputStream. inputStream = new DataInputStream(fileInputStream); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, "UTF8")); String inputLine; // Check the first line is what we expect. String firstLine = bufferedReader.readLine(); if (firstLine == null) { // This is not an multibit address book. throw new WalletLoadException("The file '" + walletInfoFilename + "' is not a valid wallet info file (empty line 1)"); } StringTokenizer tokenizer = new StringTokenizer(firstLine, SEPARATOR); int numberOfTokens = tokenizer.countTokens(); if (numberOfTokens == 2) { String magicText = tokenizer.nextToken(); String versionNumber = tokenizer.nextToken(); if (!INFO_MAGIC_TEXT.equals(magicText) || !INFO_VERSION_TEXT.equals(versionNumber)) { // This is not an multibit address book. throw new WalletLoadException("The file '" + walletInfoFilename + "' is not a valid wallet info file (wrong magic number on line 1)"); } } else { // This is not an multibit address book. throw new WalletLoadException("The file '" + walletInfoFilename + "' is not a valid wallet info file (wrong number of tokens on line 1)"); } // Read the wallet version. String secondLine = bufferedReader.readLine(); StringTokenizer walletVersionTokenizer = new StringTokenizer(secondLine, SEPARATOR); int walletVersionTokenNumber = walletVersionTokenizer.countTokens(); if (walletVersionTokenNumber == 2) { String walletVersionMarker = walletVersionTokenizer.nextToken(); String walletVersionString = walletVersionTokenizer.nextToken(); if (!WALLET_VERSION_MARKER.equals(walletVersionMarker) || !(MultiBitWalletVersion.SERIALIZED.getWalletVersionString().equals(walletVersionString) || MultiBitWalletVersion.PROTOBUF.getWalletVersionString().equals(walletVersionString) || MultiBitWalletVersion.PROTOBUF_ENCRYPTED .getWalletVersionString().equals(walletVersionString))) { // This refers to a version of the wallet we do not know // about. throw new WalletVersionException("Cannot understand wallet version of '" + walletVersionMarker + "', '" + walletVersionString + "'"); } else { // The wallet version passed in the file is used rather than // the value in the constructor if (!walletVersion.getWalletVersionString().equals(walletVersionString)) { log.debug("The wallet version in the constructor was '" + walletVersion + "'. In the wallet info file it was '" + walletVersionString + "'. Using the latter."); if (MultiBitWalletVersion.SERIALIZED.getWalletVersionString().equals(walletVersionString)) { walletVersion = MultiBitWalletVersion.SERIALIZED; } else if (MultiBitWalletVersion.PROTOBUF.getWalletVersionString().equals(walletVersionString)) { walletVersion = MultiBitWalletVersion.PROTOBUF; } else if (MultiBitWalletVersion.PROTOBUF_ENCRYPTED.getWalletVersionString().equals(walletVersionString)) { walletVersion = MultiBitWalletVersion.PROTOBUF_ENCRYPTED; } } } } else { // The format of the info format is wrong. throw new WalletVersionException("Cannot understand wallet version text of '" + secondLine + "'"); } // Read the addresses and general properties. boolean isMultilineColumnThree = false; String previousColumnOne = null; String previousColumnTwo = null; String multilineColumnThreeValue = null; while ((inputLine = bufferedReader.readLine()) != null) { if (inputLine.startsWith(RECEIVE_ADDRESS_MARKER + SEPARATOR) || inputLine.startsWith(SEND_ADDRESS_MARKER + SEPARATOR) || inputLine.startsWith(PROPERTY_MARKER + SEPARATOR)) { if (isMultilineColumnThree) { // Add previous multiline column three to model. String decodedMultiLineColumnThreeValue = decodeURLString(multilineColumnThreeValue); if (RECEIVE_ADDRESS_MARKER.equals(previousColumnOne)) { addReceivingAddress(new WalletAddressBookData(decodedMultiLineColumnThreeValue, previousColumnTwo), true); } else { if (SEND_ADDRESS_MARKER.equals(previousColumnOne)) { addSendingAddress(new WalletAddressBookData(decodedMultiLineColumnThreeValue, previousColumnTwo)); } else { if (PROPERTY_MARKER.equals(previousColumnOne)) { walletPreferences.put(previousColumnTwo, decodedMultiLineColumnThreeValue); } } } previousColumnOne = null; previousColumnTwo = null; multilineColumnThreeValue = null; isMultilineColumnThree = false; } StringTokenizer tokenizer2 = new StringTokenizer(inputLine, SEPARATOR); int numberOfTokens2 = tokenizer2.countTokens(); String columnOne = null; String columnTwo = null; String columnThree = ""; if (numberOfTokens2 == 2) { columnOne = tokenizer2.nextToken(); columnTwo = tokenizer2.nextToken(); } else { if (numberOfTokens2 == 3) { columnOne = tokenizer2.nextToken(); columnTwo = tokenizer2.nextToken(); columnThree = tokenizer2.nextToken(); } } String decodedColumnThreeValue = decodeURLString(columnThree); if (RECEIVE_ADDRESS_MARKER.equals(columnOne)) { addReceivingAddress(new WalletAddressBookData(decodedColumnThreeValue, columnTwo), true); } else { if (SEND_ADDRESS_MARKER.equals(columnOne)) { addSendingAddress(new WalletAddressBookData(decodedColumnThreeValue, columnTwo)); } else { if (PROPERTY_MARKER.equals(columnOne)) { walletPreferences.put(columnTwo, decodedColumnThreeValue); } } } previousColumnOne = columnOne; previousColumnTwo = columnTwo; multilineColumnThreeValue = columnThree; } else { // This is a multiline column 3 (typically a multiline // label). isMultilineColumnThree = true; multilineColumnThreeValue = multilineColumnThreeValue + "\n" + inputLine; } } if (isMultilineColumnThree) { // Add previous multiline column three to model. String decodedMultiLineColumnThreeValue = decodeURLString(multilineColumnThreeValue); if (RECEIVE_ADDRESS_MARKER.equals(previousColumnOne)) { addReceivingAddress(new WalletAddressBookData(decodedMultiLineColumnThreeValue, previousColumnTwo), true); } else { if (SEND_ADDRESS_MARKER.equals(previousColumnOne)) { addSendingAddress(new WalletAddressBookData(decodedMultiLineColumnThreeValue, previousColumnTwo)); } else { if (PROPERTY_MARKER.equals(previousColumnOne)) { walletPreferences.put(previousColumnTwo, decodedMultiLineColumnThreeValue); } } } previousColumnOne = null; previousColumnTwo = null; multilineColumnThreeValue = null; isMultilineColumnThree = false; } } catch (IllegalArgumentException iae) { throw new WalletLoadException("Could not load walletinfo file '" + walletInfoFilename + "'", iae); } catch (IOException ioe) { throw new WalletLoadException("Could not load walletinfo file '" + walletInfoFilename + "'", ioe); } finally { // Close the input stream if (inputStream != null) { try { inputStream.close(); } catch (IOException e) { throw new WalletLoadException("Could not close walletinfo file '" + walletInfoFilename + "'", e); } } } } /** * Create wallet info filename. * * @param walletFilename */ public static String createWalletInfoFilename(String walletFilename) { if (walletFilename == null) { return INFO_FILE_EXTENSION; } String walletInfoFilename = walletFilename; if (walletFilename.endsWith(BitcoinModel.WALLET_FILE_EXTENSION)) { walletInfoFilename = walletInfoFilename.substring(0, walletFilename.length() - BitcoinModel.WALLET_FILE_EXTENSION.length() - 1); walletInfoFilename = walletInfoFilename + "." + INFO_FILE_EXTENSION; } else { walletInfoFilename = walletInfoFilename + "." + INFO_FILE_EXTENSION; } return walletInfoFilename; } /** * Encode a string using URL encoding. * * @param stringToEncode * The string to URL encode */ public static String encodeURLString(String stringToEncode) { if (stringToEncode == null) { return ""; } try { return java.net.URLEncoder.encode(stringToEncode, "UTF-8").replace("+", ENCODED_SPACE_CHARACTER); } catch (UnsupportedEncodingException e) { // Should not happen - UTF-8 is a valid encoding. throw new WalletSaveException("Could not encode string '" + stringToEncode + "'", e); } } /** * Decode a string using URL encoding. * * @param stringToDecode * The string to URL decode */ public static String decodeURLString(String stringToDecode) { try { // Earlier multibits did not encode the info file - check if // encoded. boolean isEncoded = true; if (!stringToDecode.contains("%")) { // Not encoded. isEncoded = false; } else { // See if there is a % followed by non hex - not encoded. int percentPosition = stringToDecode.indexOf('%'); if (percentPosition > -1) { int nextCharacterPosition = percentPosition + 1; int nextNextCharacterPosition = nextCharacterPosition + 1; if (nextCharacterPosition >= stringToDecode.length() || nextNextCharacterPosition >= stringToDecode.length()) { isEncoded = false; } else { String nextCharacter = stringToDecode.substring(nextCharacterPosition, nextCharacterPosition + 1) .toLowerCase(); String nextNextCharacter = stringToDecode.substring(nextNextCharacterPosition, nextNextCharacterPosition + 1).toLowerCase(); if (!VALID_HEX_CHARACTERS.contains(nextCharacter) || !VALID_HEX_CHARACTERS.contains(nextNextCharacter)) { isEncoded = false; } } } } if (!isEncoded) { return stringToDecode; } // If there are any spaces convert them to %20s. stringToDecode = stringToDecode.replace(ENCODED_SPACE_CHARACTER, "+"); return java.net.URLDecoder.decode(stringToDecode, "UTF-8"); } catch (UnsupportedEncodingException e) { // Should not happen - UTF-8 is a valid encoding. throw new WalletLoadException("Could not decode string '" + stringToDecode + "'", e); } } public boolean isDeleted() { return deleted; } public void setDeleted(boolean deleted) { this.deleted = deleted; } public String getWalletFilename() { return walletFilename; } public MultiBitWalletVersion getWalletVersion() { return walletVersion; } public void setWalletVersion(MultiBitWalletVersion walletVersion) { this.walletVersion = walletVersion; } }