diff --git a/MANIFEST.in b/MANIFEST.in index 9b1c3f2162d1..781d3ad2b0e7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ include *.py include electron-cash include contrib/requirements/requirements.txt include contrib/requirements/requirements-hw.txt +include contrib/requirements/requirements-binaries.txt recursive-include electroncash *.py recursive-include electroncash_gui *.py recursive-include electroncash_plugins *.py diff --git a/RELEASE-NOTES b/RELEASE-NOTES index d59900f633ad..55b7f768dd91 100644 --- a/RELEASE-NOTES +++ b/RELEASE-NOTES @@ -1134,3 +1134,100 @@ and there is no warning message. - Various internal bugfixes and code refactoring #2087 #2095 #2097 #2117 #2140 #2143 #2151 #2155 #2156 #2158 #2160 #2154 #2176 (Daniel Gonzalez Gasull, Calin Culianu, Axel Gembe, Mark B Lundeberg, Malcolm Smith, Pierre K, Toporin) + +# RELEASE NOTES 4.2.5 + +- appimage build: build was failing on some host systems #2181 (SomberNight) +- Requirements: Restring PyQt5 version to >=5.12.3 and < 5.15.3 (Calin Culianu) +- Re-enabled PyQt 5.15.3 (Calin Culianu) +- OSX Fix: Allow for Mojave+ dark mode to work (requires Qt 5.15.2) (Calin Culianu) +- NSIS: Wait for the uninstaller to finish #2184 (Axel Gembe) +- NSIS: Ensure the process is not running when (un)installing #2184 (Axel Gembe) +- Servers: Add electroncash.de scalenet server #2186 (Axel Gembe) +- Servers: Fix indentation #2187 (Axel Gembe) +- Build: Use git version with patch for CVE-2021-21300 #2191 (Axel Gembe) +- setup.py: Fix typo #2192 (Axel Gembe) +- AppImage: Include libxcb into the image #2197 (Axel Gembe) +- Add requirements-binaries.txt to manifest #2200 (Jonas Lundqvist) +- Install Wizard: Add derivation path scanner #2199 (Jonas Lundqvist) +- Tweaks and fixups to the DerivationPathScanner (Calin Culianu) +- Build: Add cffi to the requirements #2203 (Axel Gembe) +- Android: add new layout screens for the choice between standard and multi-sig + wallet. (Aldin Kovačević) +- Android: reorganize code for creating a standard wallet.(Aldin Kovačević) +- Tor: Update to version 0.4.5.7 with some patches #2205 (Axel Gembe) +- OpenSSL: Update to version 1.1.1j #2206 (Axel Gembe) +- Skip path derivation scan if seed is unavailable #2208 (Jonas Lundqvist) +- Shorten boolean expression, use double quotes #2209 (Daniel Gonzalez Gasull) +- Style changes (from backport), small refactorings #2211 (Daniel Gonzalez Gasull) +- Fix PR 2211 #2218 (agilewalker) +- Add electrs.electroncash.de #2222 (Georg Engelmann) +- Removed unreachable line of code #2224 #2234 (Daniel Gonzalez Gasull) +- OpenSSL: Update to version 1.1.1k #2225 (Axel Gembe) +- Python: Update to version 3.8.9 #2226 (Axel Gembe) +- Fixed typo #2227 (Kevin Nowaczyk) +- AppImage: Update OpenSSL to version 1.1.1-1ubuntu2.1~18.04.9 + #2231 (Axel Gembe) +- Android: Added initial UI for multisg wallet creation. (Aldin Kovačević) +- Build: Verify the Python checksums #2239 (Axel Gembe) +- Android: Various commits to support multi-sig wallets (Aldin Kovačević) +- Remove CPFP (child pays for parent) (Calin Culianu) +- Update servers.json (Calin Culianu) +- Android: Using 'get_tx_info' to get the status of the transaction. + (Aldin Kovačević) +- Make make_locale check exit status of gettext commands #2259 (Malcolm Smith) +- Android: fix duplicate requirement, add missing string (Malcolm Smith) +- Android: update to Gradle 6.5 and Android Gradle plugin 4.1.2 (Malcolm Smith) +- Android: Added multisig wallets to the Android application #2279 + (Aldin Kovačević) +- Look for external plugins in ELECTRON_CASH_PATH #2301 (Jonas Lundqvist) +- Support disabling JSON-RPC server in Daemon #2305 (MrNaif2018) +- [fusion] Increase 'fuzz fee' to be tier/10^6 #1984 (Mark B. Lundeberg) +- Add feerate argument to payto/paytomany #2306 (MrNaif2018) +- Android: catch overflow errors in AmountBox; closes #2288 (Malcolm Smith) +- Android: restore thousands commas in read-only fiat amounts; closes #2246 + (Malcolm Smith) +- Android: Make PaymentRequest.has_expired always return a boolean; closes #2298 + (Malcolm Smith) +- Android: Add missing interface_lock in Network.get_server_height; closes #2173 + (Malcolm Smith) +- iOS: Support OP_RETURN transaction outputs #2307 (JOE LOYA ⚡️) +- Android: many, many, many commits fixing many things related to v4.2.4-4 + (Malcolm Smith) +- CashFusion: Add "spend only fused coins" to Send tab #2316 (Calin Culianu) +- Android: add sweep private keys command (Malcolm Smith) +- Fix daemon running in non-jsonrpc mode (MrNaif2018) +- Various Linux and Windows build fixups (Calin Culianu) +- Fixed build for OSX Mojave (Calin Culianu) +- Make OpenAlias accept 'bitcoincash:' prefix #2321 (Karol Trzeszczkowski) +- Updated checkpoints for mainnet, testnet3, and testnet4 (Calin Culianu) +- Removed support for ABC's "TaxCoin" (Calin Culianu) +- CashFusion Server: Allow testnets to have unlimited connections per IP + (Calin Culianu) +- CashFusion: Add depth checks #2325 (Jonas Lundqvist, Calin Culianu) +- CashFusion: Added a "Fusion Status" column to coins tab (Calin Culianu) +- macOS: Fix popup_widget display if running in dark mode (Calin Culianu) +- Remove bitcoin.com block explorer #2328 (Jonas Lundqvist) +- CashFusion: Clarify status of coins on fused address #2329 (Jonas Lundqvist) +- Add more accurate exchange rate for ARS #2326 (Santiago Chiabotto) + +# RELEASE NOTES 4.2.6 + +- History list - Made fiat balance changes also appear in red (scinklja) +- Implement unconfirmed invoices status (MrNaif2018) +- Add ability to forget config on exit (MrNaif2018) +- Fire payment_received event on confirmation too (MrNaif2018) +- macOS: Set minimum system version to 10.14.0 in Info.plist (cculianu) +- Add native introspection opcodes (upcoming May 2022 additions to + script) #2339 (cculianu) +- CashFusion: Default fusion depth, if checked, to 3 (cculianu) +- Trivial fix: Allow plugins to use qt/util.py filename_field (acidsploit) +- Added electrum.bitcoinverde.org to server list (Josh Green) +- Fixups for iOS 15+ (cculianu) +- Android: Remove pycparser from requirements-android.txt (Malcolm Smith) +- Android: improve string conversion script, and make it detect some errors + (Malcolm Smith) +- Fixups for Python 3.10 (cculianu) +- add kisternet v3 onion to testnet (jkister) +- Qt: Add cleanup code to avoid random segfault on exit (cculianu) +- AppImage: Fixed to work on Debian and Tails #2245 (Axel Gembe) diff --git a/android/README.md b/android/README.md index 3b3bd6351192..a216dd6f588c 100644 --- a/android/README.md +++ b/android/README.md @@ -1,9 +1,49 @@ # Electron Cash Android app +To start developing the app, just open this directory in Android Studio. + + +## Requirements + +You'll need to set up the following things before building the app: + +* Python 3.8 must be on the PATH under the name `python3.8` or `python3` on Linux/Mac, or `py` + on Windows, and it must have the packages listed in `build-requirements.txt`. +* The commands `xgettext` and `msgfmt` must be on the PATH. On Windows, the easiest way to + get these is to install MSYS2. + + +## Strings + +Most user interface strings are reused from the desktop and iOS apps. Android-specific strings +should be added to `app/src/main/python/electroncash_gui/android/strings.py`. + +The Gradle task `generateStrings` takes all localized strings in the repository, and their +translations from Crowdin, and converts them into `strings.xml` format so they can be accessed +through the Android resource API. The string IDs are generated from the first 2 words of each +string, plus as many more words as necessary to make them unique. So if any of the source +strings change, you may need to update ID references in the code. + +The `generateStrings` task is run automatically the first time you build the app, and whenever +you edit the `strings.py` file mentioned above. If you need to pick up new strings from +anywhere else in the repository, run the task `regenerateStrings`. + +Changes on Crowdin won't be picked up until a Crowdin project manager has run the "build" +command (green button on the project home page). If you're not a project manager but you want +to test a new translation, or fix an invalid translation which is blocking the build, you can +do this: + +* To prevent your changes being overwritten, temporarily edit the `generateStrings` block in + app/build.gradle to add the line `args "--no-download"`. Do not commit this change! +* Edit the string in the .po file under electroncash/locale. +* Run the task `regenerateStrings`, then build and test the app. +* Once you're happy with the result, submit the string on Crowdin. +* Ask a project manager to approve the string and run the "build" command. + + ## Release -The Android app can be built on any OS which can run the Android development tools. However, -the following automated process is available for Linux x86-64: +For public releases, the following reproducible build process should be run on Linux x86-64: If necessary, install Docker using the [instructions on its website](https://docs.docker.com/install/#supported-platforms). @@ -18,24 +58,3 @@ following configuration: Run `build.sh`. The APK will be generated in `release` in this directory. Between builds it may be helpful to free up disk space with the command `docker system prune`. - -## Development - -To start developing the app, just open this directory in Android Studio. - -### Strings - -For user interface text, the app uses the standard Android string resource system. The -`strings.xml` files are generated by the Gradle task `generateStrings`, which in turn calls the -script `contrib/make_locale` to obtain strings from elsewhere in the repository and Crowdin. - -Android-specific strings should be added to -`app/src/main/python/electroncash_gui/android/strings.py`. - -`generateStrings` is run automatically the first time you build the app, and whenever you edit -`strings.py`. If you need to pick up new strings from any of the other source files, run the -task `regenerateStrings`. - -The Android string IDs are generated from the first 2 words of each string, plus as many more -words as necessary to make them unique. So if any of the source strings change, you may need to -update ID references in the code. diff --git a/android/app/build.gradle b/android/app/build.gradle index 6d0f845b5656..0a0694ccb309 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,7 +33,7 @@ android { if (ecVersion == null) { throw new GradleException("Couldn't find version number") } - def BUILD_NUM = 3 // Distinguish multiple releases with the same version number. + def BUILD_NUM = 5 // Distinguish multiple releases with the same version number. versionName "$ecVersion-$BUILD_NUM" def verParsed = ecVersion.split(/\./).collect { Integer.parseInt(it) } versionCode((verParsed[0] * 1000000) + (verParsed[1] * 10000) + (verParsed[2] * 100) + @@ -64,7 +64,7 @@ android { python { srcDir REPO_ROOT include "electroncash/" - exclude "electroncash/locale/" + exclude "**/*.po", "**/*.pot" // The .mo files are used at runtime. include "electroncash_gui/__init__.py" include "electroncash_plugins/__init__.py" @@ -136,7 +136,9 @@ afterEvaluate { } else { executable "python$pyVersion" } - args scriptFilename, STRINGS_DIR + args scriptFilename + args "--out", STRINGS_DIR + args "--ignore-unknown-keywords", "tor_binary_name_capitalized" // See #1989. } for (variant in android.applicationVariants) { tasks.getByName("generate${variant.name.capitalize()}Resources") @@ -150,7 +152,7 @@ afterEvaluate { dependsOn ("deleteStrings", "generateStrings") } - // Remove unnecessary requirements (#2162). + // Remove unnecessary or duplicate requirements. task("generateRequirementsTxt") { inputs.file "$REPO_ROOT/contrib/deterministic-build/requirements.txt" outputs.file REQUIREMENTS_TXT @@ -160,7 +162,7 @@ afterEvaluate { file(inputs.files.singleFile).eachLine { line -> buffer.append(line + "\n") if (!line.endsWith("\\")) { - if (!(buffer =~ /^(pip|QDarkStyle|setuptools|wheel)==/)) { + if (!(buffer =~ /^(cffi|pip|QDarkStyle|setuptools|wheel)==/)) { output.print(buffer) } buffer.setLength(0) diff --git a/android/app/generate_strings.py b/android/app/generate_strings.py index d1fada9695d7..cea0bc8676da 100644 --- a/android/app/generate_strings.py +++ b/android/app/generate_strings.py @@ -6,6 +6,24 @@ # This script's requirements are listed in build-requirements.txt. It also runs # contrib/make_locale, which requires the external commands `xgettext` and `msgfmt`. +# Strings to test when changing this script: +# +# * String with no fields, e.g. "New wallet" +# * % syntax: +# * Single implicit field, e.g. "%s bytes" +# * Single indexed field (no examples in catalog) +# * Multiple implicit fields (no examples in catalog) +# * Multiple indexed fields, e.g. "%1$d tx (%2$d unverified)" +# * Plurals, e.g. "Sweep %d input(s)" +# * {} syntax: +# * Single implicit field, e.g. "{} copied to clipboard" +# * Single keyword field, e.g. "Size: {size} bytes" (no examples on Android, except for +# the plural below) +# * Multiple implicit fields, e.g. "{} contacts exported to '{}'" (no examples on Android) +# * Multiple keyword fields, e.g. "Please wait... {num}/{total}" (no examples on Android) +# * Plurals, e.g. "{conf} confirmation(s)" + + import argparse import babel from collections import Counter, defaultdict @@ -45,40 +63,53 @@ "id": "in", } + +catalog_errors = 0 + +class CatalogError(Exception): + def __init__(self, message): + super().__init__(message) + global catalog_errors + catalog_errors += 1 + + def main(): + global args args = parse_args() if not args.no_download: log("Running make_locale") run([sys.executable, join(EC_ROOT, "contrib/make_locale")], check=True) locale_dir = join(EC_ROOT, "electroncash/locale") + src_strings = read_catalog(join(locale_dir, "messages.pot"), "en", "US") + lang_strings = defaultdict(list) for lang_region in [name for name in os.listdir(locale_dir) if isdir(join(locale_dir, name)) and name != '__pycache__']: lang, region = lang_region.split("_") - catalog = read_catalog(join(locale_dir, lang_region, "LC_MESSAGES", "electron-cash.mo"), + catalog = read_catalog(join(locale_dir, lang_region, "LC_MESSAGES", "electron-cash.po"), lang, region) lang_strings[lang].append((region, catalog)) - src_strings = read_catalog(join(locale_dir, "messages.pot"), "en", "US") - ids = make_ids(src_strings) + if catalog_errors: + sys.exit(1) - log(f"Writing to {args.res_dir}") + log(f"Writing to {args.out}") + ids = make_ids(src_strings) + write_xml(args.out, "", src_strings, ids) for lang, region_strings in lang_strings.items(): region_strings.sort(key=region_order, reverse=True) for i, (region, strings) in enumerate(region_strings): - write_xml(args.res_dir, lang if i == 0 else "{}-r{}".format(lang, region), + write_xml(args.out, lang if i == 0 else "{}-r{}".format(lang, region), strings, ids) - # The main strings.xml should be generated last, because this script will only be - # automatically run if it's missing. - write_xml(args.res_dir, "", src_strings, ids) - def read_catalog(filename, lang, region): + lang_region = f"{lang}_{region}" + try: is_pot = filename.endswith(".pot") - f = (polib.mofile if filename.endswith(".mo") else polib.pofile)(filename) + f = polib.pofile(filename) pf = f.metadata.get("Plural-Forms") if pf is None: quantities = None @@ -87,53 +118,147 @@ def read_catalog(filename, lang, region): else: match = re.search(r"nplurals=(\d+);", pf) if not match: - raise Exception("Failed to parse Plural-Forms") + raise CatalogError("Failed to parse Plural-Forms") nplurals = int(match.group(1)) try: - locale = babel.Locale("{}_{}".format(lang, region)) + locale = babel.Locale(lang_region) except babel.UnknownLocaleError: locale = babel.Locale(lang) quantities = sorted(locale.plural_form.tags | {"other"}, key=["zero", "one", "two", "few", "many", "other"].index) if len(quantities) != nplurals: - raise Exception("Plural-Forms says nplurals={}, but Babel has {} plural tags " - "for this language {}" - .format(nplurals, len(quantities), quantities)) + raise CatalogError(f"Plural-Forms says {nplurals=}, but Babel has " + f"{len(quantities)} plural tags for this language {quantities}") + except CatalogError as e: + log(f"{filename}: {e}", file=sys.stderr) + return None + + catalog = {} + for entry in f: + try: + msgid = entry.msgid + if is_excluded(msgid): + continue + + if entry.msgid_plural: + # Get field indices from msgid_plural, because sometimes msgid just contains a + # singular word with no field. + indices = get_indices(entry.msgid_plural) + + # Only include the string if it has a complete set of plural translations, + # otherwise some numbers would cause it to appear blank. + msgstr_plural = ({0: msgid, 1: entry.msgid_plural} if is_pot + else entry.msgstr_plural) + if not all(s for s in msgstr_plural.values()): + continue - catalog = {} - for entry in f: - try: - msgid = entry.msgid - if is_excluded(msgid): + if quantities is None: + raise CatalogError("msgid {msgid!r} has plurals, but file has no Plural-Forms") + catalog[msgid] = {quantities[i]: fix_format(s, indices) + for i, s in msgstr_plural.items()} + else: + indices = get_indices(msgid) + msgstr = msgid if is_pot else entry.msgstr + if not msgstr: continue + catalog[msgid] = fix_format(msgstr, indices) + except CatalogError as e: + log(f"{filename}:{entry.linenum}: {e}", file=sys.stderr) + + return catalog + + +# Takes a source string in {} or % format, and returns a dict mapping its field keywords and +# indices (explicit or implicit) to % field indices to use in the output. +def get_indices(s): + indices = {} - # Replace Python str.format syntax with Java String.format syntax. - keywords = re.findall(r"\{(\w+)\}", msgid) - def fix_format(s): - s = s.replace("{}", "%s") - for k in keywords: - s = s.replace("{" + k + "}", - "%{}$s".format(keywords.index(k) + 1)) - return s - - msgid = fix_format(msgid) - if entry.msgid_plural: - msgstr_plural = ({0: msgid, 1: entry.msgid_plural} if is_pot - else entry.msgstr_plural) - if quantities is None: - raise Exception("File contains a plural entry, but has no Plural-Forms") - catalog[msgid] = {quantities[i]: fix_format(s) - for i, s in msgstr_plural.items()} - else: - catalog[msgid] = msgid if is_pot else fix_format(entry.msgstr) - except Exception: - raise Exception("Failed to process entry '{}'".format(entry.msgid)) - return catalog - - except Exception: - raise Exception("Failed to process '{}'".format(filename)) + # {} field indices are assigned in the order each field first appears in the string. + matches = get_brace_fields(s) + if matches: + for _, index in matches: + if index not in indices: + indices[index] = len(indices) + 1 + + # % field indices are taken directly from the string, so they may leave gaps. + else: + matches = get_percent_fields(s) + for _, index in matches: + indices[index] = int(index) + + return indices + + +# Takes a string in {} or % format, and converts it to Java-compatible % format, taking into +# account the given field indices generated by get_indices. Strings which are already in % +# format will be checked for errors and returned unchanged. +# +# A string having fewer fields than the msgid isn't necessarily an error, e.g. some +# translations omit a numeric field and use the equivalent of "a" or "one" instead. But if a +# string references a field that isn't in the msgid, that usually causes a crash (e.g. #2358). +def fix_format(s, indices): + INDEX_ERROR = "string {!r} uses field {!r}, which isn't in the msgid" + + matches = get_brace_fields(s) + if matches: + # The string uses {} format, so any % signs cannot be fields, and should be escaped. + result = s.replace("%", "%%") + + for field, index in matches: + try: + index_out = indices[index] + except KeyError: + if index not in args.ignore_unknown_keywords: + raise CatalogError(INDEX_ERROR.format(s, index)) + else: + result = result.replace(field, + "%s" if (len(indices) == 1) else f"%{index_out}$s", + 1) + return result + + else: + matches = get_percent_fields(s) + for field, index in matches: + # Only numeric fields allow a space flag. A space in any other field type is + # probably a typo (e.g. #1899) or an unescaped % sign. + if (" " in field) and (field[-1].lower() not in "doxefga"): + raise CatalogError(f"in string {s!r}, non-numeric field {field!r} contains a space") + + if index not in indices: + raise CatalogError(INDEX_ERROR.format(s, index)) + + return s + + +# Return a list of {} fields in a string. Each field is returned as a tuple of: +# * The entire field, including the braces. +# * The field keyword or index as a string, with implicit fields numbered from 0. +def get_brace_fields(s): + # TODO: handle more complex syntax like "{x:.3f}". + matches = re.findall(r"(\{(\w+)?\})", s) + return fill_implicit(matches, 0) + + +# Return a list of % fields in a string. Each field is returned as a tuple of: +# * The entire field, including the %. +# * The field index as a string, with implicit fields numbered from 1. +def get_percent_fields(s): + matches = re.findall(r"(%(?:(\d+)\$)?.*?[a-zA-Z])", s.replace("%%", "")) + return fill_implicit(matches, 1) + + +def fill_implicit(matches, start): + next = start + + result = [] + for i, (field, index) in enumerate(matches): + if not index: + index = str(next) + next += 1 + result.append((field, index)) + return result # The region with the most translations is output without a country code so it will act as @@ -153,8 +278,11 @@ def region_order(item): def parse_args(): ap = argparse.ArgumentParser() - ap.add_argument("--no-download", action="store_true") - ap.add_argument("res_dir", type=abspath) + ap.add_argument("--no-download", action="store_true", help="Don't run make_locale") + ap.add_argument("--ignore-unknown-keywords", metavar="KEYWORD", nargs="+", default=[], + help="Keywords which can appear in a string without being in the msgid") + ap.add_argument("--out", metavar="DIR", type=abspath, required=True, + help="Output resources directory") return ap.parse_args() @@ -274,8 +402,8 @@ def write_xml(res_dir, res_suffix, strings, ids): with open(join(abs_dir_name, base_name), "w", encoding="UTF-8") as f: print('', file=f) print(''.format(SCRIPT_NAME, timestamp), file=f) - print('', file=f) + print('', + file=f) print('', file=f) for id, tgt in output: if isinstance(tgt, dict): @@ -299,7 +427,7 @@ def write_xml(res_dir, res_suffix, strings, ids): # Android-specific syntax # (https://developer.android.com/guide/topics/resources/string-resource#escaping_quotes) - (re.compile(r"^([@?])"), r"\1"), + (re.compile(r"^([@?])"), r"\\\1"), ("'", r"\'"), ('"', r'\"'), ("\n", r"\n"), diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Addresses.kt b/android/app/src/main/java/org/electroncash/electroncash3/Addresses.kt index b3a0f896aeac..9627a64955b3 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Addresses.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Addresses.kt @@ -155,7 +155,7 @@ class AddressDialog : DetailDialog() { } tvTxCount.movementMethod = LinkMovementMethod.getInstance() - tvBalance.text = ltr(formatSatoshisAndFiat(addrModel.balance)) + tvBalance.text = ltr(formatSatoshisAndFiat(addrModel.balance, commas=true)) } override fun onFirstShowDialog() { diff --git a/android/app/src/main/java/org/electroncash/electroncash3/AmountBox.kt b/android/app/src/main/java/org/electroncash/electroncash3/AmountBox.kt index 9a53f5b5f34e..b65cba55c4b5 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/AmountBox.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/AmountBox.kt @@ -31,7 +31,7 @@ class AmountBox(val dialog: Dialog) { dialog.etAmount -> { etOther = dialog.etFiat formatOther = { - formatFiatAmount(toSatoshis(s.toString())) ?: "" + formatFiatAmount(toSatoshis(s.toString()), commas=false) ?: "" } } dialog.etFiat -> { diff --git a/android/app/src/main/java/org/electroncash/electroncash3/App.kt b/android/app/src/main/java/org/electroncash/electroncash3/App.kt index e64cfa8febd7..a865b27ae719 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/App.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/App.kt @@ -60,11 +60,10 @@ class App : Application() { } -fun runOnUiThread(r: () -> Unit) { runOnUiThread(Runnable { r() }, false) } -fun postToUiThread(r: () -> Unit) { runOnUiThread(Runnable { r() }, true) } +fun runOnUiThread(r: () -> Unit) { runOnUiThread(Runnable { r() }) } -fun runOnUiThread(r: Runnable, post: Boolean) { - if (onUiThread() && !post) { +fun runOnUiThread(r: Runnable) { + if (onUiThread()) { r.run() } else { mainHandler.post(r) diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Caption.kt b/android/app/src/main/java/org/electroncash/electroncash3/Caption.kt index 32f8c92d9686..279cdac6b038 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Caption.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Caption.kt @@ -65,7 +65,7 @@ private fun getCaption(): Caption { if (wallet.callAttr("is_fully_settled_down").toBoolean()) { // get_balance returns the tuple (confirmed, unconfirmed, unmatured) val balance = wallet.callAttr("get_balance").asList().get(0).toLong() - subtitle = ltr(formatSatoshisAndFiat(balance)) + subtitle = ltr(formatSatoshisAndFiat(balance, commas=true)) } else { // get_addresses copies the list, which may be very large. val addrCount = wallet.callAttr("get_receiving_addresses").asList().size + diff --git a/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt b/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt index 6151c5673f5f..f921b578a100 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt @@ -2,10 +2,21 @@ package org.electroncash.electroncash3 import android.content.ClipboardManager import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.chaquo.python.Kwarg +import com.chaquo.python.PyException +import com.chaquo.python.PyObject +import com.chaquo.python.PyObject.fromJava import com.google.zxing.integration.android.IntentIntegrator import kotlinx.android.synthetic.main.load.* +import kotlinx.android.synthetic.main.load.tvStatus +import kotlinx.android.synthetic.main.signed_transaction.* +import kotlinx.android.synthetic.main.sweep.* val libTransaction by lazy { libMod("transaction") } @@ -17,12 +28,13 @@ val libTransaction by lazy { libMod("transaction") } // Valid transaction quickly show up in transactions. class ColdLoadDialog : AlertDialogFragment() { + override fun onBuildDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.load_transaction) .setView(R.layout.load) .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.qr_code, null) - .setPositiveButton(R.string.send, null) + .setNeutralButton(R.string.scan_qr, null) + .setPositiveButton(R.string.OK, null) } override fun onShowDialog() { @@ -42,31 +54,196 @@ class ColdLoadDialog : AlertDialogFragment() { } private fun updateUI() { - val currenttext = etTransaction.text - //checks if text is blank. further validations can be added here - dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = currenttext.isNotBlank() + val tx = txFromHex(etTransaction.text.toString()) + updateStatusText(tvStatus, tx) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = + canSign(tx) || canBroadcast(tx) } // Receives the result of a QR scan. override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result != null && result.contents != null) { - etTransaction.setText(result.contents) + // Try to decode the QR content as Base43; if that fails, treat it as is + val txHex: String = try { + baseDecode(result.contents, 43) + } catch (e: PyException) { + result.contents + } + etTransaction.setText(txHex) } else { super.onActivityResult(requestCode, resultCode, data) } } fun onOK() { - val tx = libTransaction.callAttr("Transaction", etTransaction.text.toString()) + val txHex = etTransaction.text.toString() + val tx = txFromHex(txHex) + try { - if (!daemonModel.isConnected()) { - throw ToastException(R.string.not_connected) + if (canBroadcast(tx)) { + showDialog(this, SignedTransactionDialog().apply { arguments = Bundle().apply { + putString("txHex", txHex) + }}) + dismiss() + } else { + signLoadedTransaction(txHex) } - val result = daemonModel.network.callAttr("broadcast_transaction", tx) - checkBroadcastResult(result) - toast(R.string.the_string, Toast.LENGTH_LONG) - dismiss() + } catch (e: ToastException) { + e.show() + } + } + + private fun signLoadedTransaction(txHex: String) { + val arguments = Bundle().apply { + putString("txHex", txHex) + putBoolean("unbroadcasted", true) + } + val dialog = SendDialog() + showDialog(this, dialog.apply { setArguments(arguments) }) + } +} + +private fun updateStatusText(idTxStatus: TextView, tx: PyObject) { + try { + val txInfo = daemonModel.wallet!!.callAttr("get_tx_info", tx) + if (txInfo["amount"] == null && !canBroadcast(tx)) { + idTxStatus.setText(R.string.transaction_unrelated) + } else { + idTxStatus.setText(txInfo["status"].toString()) + } + } catch (e: PyException) { + idTxStatus.setText(R.string.invalid) + } +} + + +class SignedTransactionDialog : TaskLauncherDialog() { + private val tx: PyObject by lazy { + txFromHex(arguments!!.getString("txHex")!!) + } + private lateinit var description: String + + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setView(R.layout.signed_transaction) + .setNegativeButton(R.string.close, null) + .setPositiveButton(R.string.send, null) + } + + override fun onShowDialog() { + super.onShowDialog() + + fabCopy.setOnClickListener { + copyToClipboard(tx.toString(), R.string.transaction) + } + showQR(imgQR, baseEncode(tx.toString(), 43)) + updateStatusText(tvStatus, tx) + + if (!canBroadcast(tx)) { + hideDescription(this) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } + + override fun onPreExecute() { + description = etDescription.text.toString() + } + + override fun doInBackground() { + broadcastTransaction(daemonModel.wallet!!, tx, description) + } + + override fun onPostExecute(result: Unit) { + toast(R.string.payment_sent, Toast.LENGTH_SHORT) + } +} + +fun hideDescription(dialog: DialogFragment) { + for (view in listOf(dialog.tvDescriptionLabel, dialog.etDescription)) { + view.visibility = View.GONE + } +} + + +class SweepDialog : TaskLauncherDialog() { + lateinit var input: String + + init { + dismissAfterExecute = false + } + + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.sweep_private) + .setView(R.layout.sweep) + .setNeutralButton(R.string.scan_qr, null) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + } + + override fun onShowDialog() { + super.onShowDialog() + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { scanQR(this) } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result != null && result.contents != null) { + appendLine(etInput, result.contents) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onPreExecute() { + input = etInput.text.toString() + } + + override fun doInBackground(): PyObject { + daemonModel.assertConnected() + val privkeys = input.split(Regex("\\s+")).filter { !it.isEmpty() }.toTypedArray() + try { + return libWallet.callAttr("sweep_preparations", privkeys, daemonModel.network) + } catch (e: PyException) { + throw ToastException(e) + } + } + + override fun onPostExecute(result: PyObject) { + // Convert objects to serializable form so we can pass them in an argument. + val inputs = result.asList()[0] + for (i in inputs.asList()) { + val iMap = i.asMap() + iMap[fromJava("address")] = fromJava(iMap[fromJava("address")].toString()) + } + + val wallet = daemonModel.wallet!! + try { + showDialog(this, SendDialog().setArguments { + putString("address", wallet.callAttr("get_receiving_address").toString()) + putString("inputs", inputs.repr()) + putString("sweepKeypairs", result.asList()[1].repr()) + }) } catch (e: ToastException) { e.show() } } -} \ No newline at end of file +} + + +fun txFromHex(hex: String) = + libTransaction.callAttr("Transaction", hex, Kwarg("sign_schnorr", signSchnorr()))!! + +fun canSign(tx: PyObject): Boolean { + return try { + !tx.callAttr("is_complete").toBoolean() && + daemonModel.wallet!!.callAttr("can_sign", tx).toBoolean() + } catch (e: PyException) { + false + } +} + +fun canBroadcast(tx: PyObject): Boolean { + return try { + tx.callAttr("is_complete").toBoolean() + } catch (e: PyException) { + false + } +} diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Contacts.kt b/android/app/src/main/java/org/electroncash/electroncash3/Contacts.kt index f054d52cdd7a..998394fccb16 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Contacts.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Contacts.kt @@ -66,7 +66,7 @@ class ContactDialog : DetailDialog() { setView(R.layout.contact_detail) setNegativeButton(android.R.string.cancel, null) setPositiveButton(android.R.string.ok, null) - setNeutralButton(if (existingContact == null) R.string.qr_code + setNeutralButton(if (existingContact == null) R.string.scan_qr else R.string.delete, null) } diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Daemon.kt b/android/app/src/main/java/org/electroncash/electroncash3/Daemon.kt index 1b8b0b92c1a5..03ff81957277 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Daemon.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Daemon.kt @@ -32,8 +32,14 @@ class DaemonModel(val config: PyObject) { val walletName: String? get() { val wallet = this.wallet - return if (wallet == null) null else wallet.callAttr("basename").toString() + return wallet?.callAttr("basename")?.toString() } + val walletType: String? + get() { + return if (wallet == null) null else commands.callAttr("get", "wallet_type").toString() + } + val scriptType: String? + get() = wallet?.get("txin_type").toString() lateinit var watchdog: Runnable @@ -64,6 +70,12 @@ class DaemonModel(val config: PyObject) { fun isConnected() = network.callAttr("is_connected").toBoolean() + /** This should be called before doing anything which blocks the UI waiting for the + * network. Otherwise it would probably hang for 30 seconds until it timed out. */ + fun assertConnected() { + if (!isConnected()) throw ToastException(R.string.not_connected) + } + fun listWallets(): List { return commands.callAttr("list_wallets").asList().map { it.toString() } } diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Dialog.kt b/android/app/src/main/java/org/electroncash/electroncash3/Dialog.kt index a351b0f4c6dc..3da660721583 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Dialog.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Dialog.kt @@ -35,6 +35,12 @@ abstract class AlertDialogFragment : DialogFragment() { var suppressView = false var focusOnStop = View.NO_ID + fun setArguments(block: Bundle.() -> Unit): AlertDialogFragment { + val args = arguments ?: Bundle() + setArguments(args.apply(block)) + return this + } + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { val builder = AlertDialog.Builder(context!!) onBuildDialog(builder) @@ -246,12 +252,8 @@ abstract class TaskDialog : DialogFragment() { private fun onFinished(body: () -> Unit) { if (model.state == Thread.State.RUNNABLE) { model.state = Thread.State.TERMINATED - - // If we're inside onStart, fragment transactions are unsafe (#2154). - postToUiThread { - body() - dismiss() - } + body() + dismiss() } } diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Exchange.kt b/android/app/src/main/java/org/electroncash/electroncash3/Exchange.kt index 74b8872252ce..b1c39b0f2889 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Exchange.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Exchange.kt @@ -1,6 +1,8 @@ package org.electroncash.electroncash3 +import android.widget.Toast import com.chaquo.python.Kwarg +import com.chaquo.python.PyException // on_history is not included, because that's also generated by wallet updates. val EXCHANGE_CALLBACKS = setOf("on_quotes") @@ -36,22 +38,24 @@ fun fiatToSatoshis(s: String): Long? { return fx.callAttr("fiat_to_amount", s.toDouble())?.toLong() } catch (e: NumberFormatException) { throw ToastException(R.string.Invalid_amount) + } catch (e: PyException) { + throw if (e.message!!.startsWith("OverflowError")) ToastException(e) else e } } -fun formatSatoshisAndFiat(amount: Long): String { +fun formatSatoshisAndFiat(amount: Long, commas: Boolean): String { var result = formatSatoshisAndUnit(amount) - val fiat = formatFiatAmount(amount) + val fiat = formatFiatAmount(amount, commas) if (fiat != null) { result += " ($fiat ${formatFiatUnit()})" } return result } -fun formatFiatAmount(amount: Long): String? { +fun formatFiatAmount(amount: Long, commas: Boolean): String? { if (!fiatEnabled()) return null - val amountStr = fx.callAttr("format_amount", amount, Kwarg("commas", false)).toString() + val amountStr = fx.callAttr("format_amount", amount, Kwarg("commas", commas)).toString() return if (amountStr.isEmpty()) null else amountStr } diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Main.kt b/android/app/src/main/java/org/electroncash/electroncash3/Main.kt index 028291576a90..77690fd09128 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Main.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Main.kt @@ -4,6 +4,7 @@ package org.electroncash.electroncash3 import android.annotation.SuppressLint import android.app.Activity +import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration import android.net.Uri @@ -17,17 +18,23 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.AdapterView import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.FileProvider +import androidx.core.view.MenuCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.lifecycle.observe import com.chaquo.python.Kwarg +import com.chaquo.python.PyException +import com.chaquo.python.PyObject import kotlinx.android.synthetic.main.main.* +import kotlinx.android.synthetic.main.show_master_key.walletMasterKey import kotlinx.android.synthetic.main.wallet_export.* +import kotlinx.android.synthetic.main.wallet_information.* import kotlinx.android.synthetic.main.wallet_open.* import kotlinx.android.synthetic.main.wallet_rename.* import java.io.File @@ -208,11 +215,9 @@ class MainActivity : AppCompatActivity(R.layout.main) { val wallet = daemonModel.wallet if (wallet != null) { menuInflater.inflate(R.menu.wallet, menu) + MenuCompat.setGroupDividerEnabled(menu, true) menu.findItem(R.id.menuUseChange)!!.isChecked = wallet.get("use_change")!!.toBoolean() - if (!wallet.callAttr("has_seed").toBoolean()) { - menu.findItem(R.id.menuShowSeed).isEnabled = false - } } return true } @@ -230,24 +235,19 @@ class MainActivity : AppCompatActivity(R.layout.main) { } } R.id.menuChangePassword -> showDialog(this, PasswordChangeDialog()) - R.id.menuShowSeed -> { showDialog(this, SeedPasswordDialog()) } - R.id.menuExportSigned -> { + R.id.menuWalletInformation -> { showDialog(this, WalletInformationDialog()) } + R.id.menuSignTx -> { try { showDialog(this, SendDialog().apply { arguments = Bundle().apply { putBoolean("unbroadcasted", true) } }) } catch (e: ToastException) { e.show() } } - R.id.menuLoadSigned -> { showDialog(this, ColdLoadDialog()) } - R.id.menuRename -> showDialog(this, WalletRenameDialog().apply { - arguments = Bundle().apply { putString("walletName", daemonModel.walletName) } - }) + R.id.menuLoadTx -> { showDialog(this, ColdLoadDialog()) } + R.id.menuSweep -> showDialog(this, SweepDialog()) R.id.menuExport -> showDialog(this, WalletExportDialog().apply { arguments = Bundle().apply { putString("walletName", daemonModel.walletName) } }) - R.id.menuDelete -> showDialog(this, WalletDeleteConfirmDialog().apply { - arguments = Bundle().apply { putString("walletName", daemonModel.walletName) } - }) R.id.menuClose -> showDialog(this, WalletCloseDialog()) else -> throw Exception("Unknown item $item") } @@ -399,7 +399,12 @@ class WalletOpenDialog : PasswordDialog() { val walletName by lazy { arguments!!.getString("walletName")!! } override fun onPassword(password: String): String { - daemonModel.loadWallet(walletName, password) + try { + daemonModel.loadWallet(walletName, password) + } catch (e: PyException) { + throw if (e.message!!.startsWith("OSError")) // Probably a corrupt file (#2232) + ToastException(e) else e + } return walletName } @@ -607,11 +612,7 @@ class WalletExportDialog : TaskLauncherDialog() { override fun onPreExecute() { exportFileName = etExportFileName.text.toString() - if (exportFileName.contains('/')) { - toast(R.string.filenames_cannot) - } else if (exportFileName.isEmpty()) { - toast(R.string.name_is) - } + validateFilename(exportFileName) } override fun doInBackground(): Uri { @@ -654,15 +655,75 @@ class SeedPasswordDialog : PasswordDialog() { } } - class SeedDialog : AlertDialogFragment() { override fun onBuildDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.Wallet_seed) - .setView(R.layout.wallet_new_2) - .setPositiveButton(android.R.string.ok, null) + .setView(R.layout.wallet_new_2) + .setPositiveButton(android.R.string.ok, null) } override fun onShowDialog() { setupSeedDialog(this) } } + +class WalletInformationDialog : AlertDialogFragment() { + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setView(R.layout.wallet_information) + .setPositiveButton(android.R.string.ok, null) + + if (daemonModel.wallet!!.callAttr("has_seed").toBoolean()) { + builder.setNeutralButton(R.string.show_seed, null) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + idWalletName.setText(daemonModel.walletName) + idWalletType.setText(daemonModel.walletType) + idScriptType.setText(daemonModel.scriptType) + + val mpks = daemonModel.wallet!!.callAttr("get_master_public_keys")?.asList() + if (mpks != null && mpks.size != 0) { + setupMasterKeys(mpks) + } else { + // Imported wallets do not have a master public key. + tvMasterPublicKey.setVisibility(View.GONE) + spnCosigners.setVisibility(View.GONE) + walletMasterKey.setVisibility(View.GONE) + // Using View.INVISIBLE on the 'Copy' button to preserve layout. + (fabCopyMasterKey as View).setVisibility(View.INVISIBLE) + } + + dialog.getButton(DialogInterface.BUTTON_NEUTRAL)?.setOnClickListener { + showDialog(this, SeedPasswordDialog()) + } + } + + private fun setupMasterKeys(mpks: List) { + fabCopyMasterKey.setOnClickListener { + val textToCopy = walletMasterKey.text + copyToClipboard(textToCopy, R.string.Master_public_key) + } + walletMasterKey.setFocusable(false) + + // For multisig wallets, display a spinner with selectable cosigners. + if (mpks.size > 1) { + tvMasterPublicKey.setText(R.string.Master_public_keys) + + val captions = List(mpks.size, { getString(R.string.cosigner__d, it + 1) }) + spnCosigners.adapter = SimpleArrayAdapter(context!!, captions) + spnCosigners.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, + position: Int, id: Long) { + walletMasterKey.setText(mpks[position].toString()) + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } else { + // For a standard wallet, display the single master public key. + walletMasterKey.setText(mpks[0].toString()) + spnCosigners.setVisibility(View.GONE) + } + } +} diff --git a/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt b/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt index 92ab12fe2abc..37ed4523fdc7 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt @@ -5,12 +5,19 @@ import android.content.Intent import android.os.Bundle import android.text.Selection import android.view.View +import android.widget.EditText +import android.widget.SeekBar import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment import com.chaquo.python.Kwarg import com.chaquo.python.PyException +import com.chaquo.python.PyObject import com.google.zxing.integration.android.IntentIntegrator +import kotlinx.android.synthetic.main.choose_keystore.* +import kotlinx.android.synthetic.main.multisig_cosigners.* +import kotlinx.android.synthetic.main.show_master_key.* import kotlinx.android.synthetic.main.wallet_new.* import kotlinx.android.synthetic.main.wallet_new_2.* import kotlin.properties.Delegates.notNull @@ -19,6 +26,9 @@ import kotlin.properties.Delegates.notNull val libKeystore by lazy { libMod("keystore") } val libWallet by lazy { libMod("wallet") } +val MAX_COSIGNERS = 15 +val COSIGNER_OFFSET = 2 // min. number of multisig cosigners = 2 +val SIGNATURE_OFFSET = 1 // min. number of req. multisig signatures = 1 class NewWalletDialog1 : AlertDialogFragment() { override fun onBuildDialog(builder: AlertDialog.Builder) { @@ -30,7 +40,7 @@ class NewWalletDialog1 : AlertDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - spnType.adapter = MenuAdapter(context!!, R.menu.wallet_type) + spnWalletType.adapter = MenuAdapter(context!!, R.menu.wallet_kind) } override fun onShowDialog() { @@ -39,25 +49,24 @@ class NewWalletDialog1 : AlertDialogFragment() { val name = etName.text.toString() validateWalletName(name) val password = confirmPassword(dialog) - val nextDialog: DialogFragment val arguments = Bundle().apply { putString("name", name) putString("password", password) } - val walletType = spnType.selectedItemId.toInt() - if (walletType in listOf(R.id.menuCreateSeed, R.id.menuRestoreSeed)) { - nextDialog = NewWalletSeedDialog() - val seed = if (walletType == R.id.menuCreateSeed) - daemonModel.commands.callAttr("make_seed").toString() - else null - arguments.putString("seed", seed) - } else if (walletType == R.id.menuImport) { - nextDialog = NewWalletImportDialog() - } else if (walletType == R.id.menuImportMaster) { - nextDialog = NewWalletImportMasterDialog() - } else { - throw Exception("Unknown item: ${spnType.selectedItem}") + val nextDialog = when (spnWalletType.selectedItemId.toInt()) { + R.id.menuStandardWallet -> { + KeystoreDialog() + } + R.id.menuMultisigWallet -> { + CosignerDialog() + } + R.id.menuImport -> { + NewWalletImportDialog() + } + else -> { + throw Exception("Unknown item: ${spnWalletType.selectedItem}") + } } showDialog(this, nextDialog.apply { setArguments(arguments) }) } catch (e: ToastException) { e.show() } @@ -65,19 +74,32 @@ class NewWalletDialog1 : AlertDialogFragment() { } } +fun closeDialogs(targetFragment: Fragment) { + val sfm = targetFragment.activity!!.supportFragmentManager + val fragments = sfm.fragments + for (frag in fragments) { + if (frag is DialogFragment) { + frag.dismiss() + } + } +} -fun validateWalletName(name: String) { +fun validateFilename(name: String) { if (name.isEmpty()) { throw ToastException(R.string.name_is) } if (name.contains("/")) { - throw ToastException(R.string.wallet_names) + throw ToastException(R.string.filenames_cannot) } if (name.toByteArray().size > 200) { // The filesystem limit is probably 255, but we need to leave room for the temporary // filename suffix. - throw ToastException(R.string.wallet_name_is_too) + throw ToastException(R.string.filename_is) } +} + +fun validateWalletName(name: String) { + validateFilename(name) if (daemonModel.listWallets().contains(name)) { throw ToastException(R.string.a_wallet_with_that_name_already_exists_please_enter) } @@ -94,34 +116,166 @@ fun confirmPassword(dialog: Dialog): String { return password } +// Choose the way of generating the wallet (new seed, import seed, etc.) +class KeystoreDialog : AlertDialogFragment() { + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.New_wallet) + .setView(R.layout.choose_keystore) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(R.string.back, null) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + updateArguments(arguments!!) + } + + fun updateArguments(arguments: Bundle?) { + super.setArguments(arguments) + + /* Handle dialog title for cosigners */ + val keystores = arguments!!.getStringArrayList("keystores") + if (keystores != null) { + dialog.setTitle(multisigTitle(arguments)) + } + + val keystoreMenu: Int + if (keystores != null && keystores.size != 0) { + keystoreMenu = R.menu.cosigner_type + keystoreDesc.setText(R.string.add_a) + } else { + keystoreDesc.setText(R.string.do_you_want_to_create) + keystoreMenu = R.menu.wallet_type + } + spnType.adapter = MenuAdapter(context!!, keystoreMenu) + } + + override fun onShowDialog() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + try { + val nextDialog: DialogFragment + val nextArguments = Bundle(arguments) + val keystoreType = spnType.selectedItemId.toInt() + if (keystoreType in listOf(R.id.menuCreateSeed, R.id.menuRestoreSeed)) { + nextDialog = NewWalletSeedDialog() + val seed = if (keystoreType == R.id.menuCreateSeed) + daemonModel.commands.callAttr("make_seed").toString() + else null + nextArguments.putString("seed", seed) + } else if (keystoreType in listOf(R.id.menuImportMaster)) { + nextDialog = NewWalletImportMasterDialog() + } else { + throw Exception("Unknown item: ${spnType.selectedItem}") + } + nextDialog.setArguments(nextArguments) + showDialog(this, nextDialog) + } catch (e: ToastException) { e.show() } + } + } +} + +private fun multisigTitle(arguments: Bundle) = + (app.getString(R.string.Add_cosigner) + " " + + app.getString(R.string.__1_d, arguments.getStringArrayList("keystores")!!.size + 1, + arguments.getInt("cosigners"))) + -abstract class NewWalletDialog2 : TaskLauncherDialog() { +abstract class NewWalletDialog2 : TaskLauncherDialog() { var input: String by notNull() override fun onBuildDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.New_wallet) - .setView(R.layout.wallet_new_2) + builder.setView(R.layout.wallet_new_2) .setPositiveButton(android.R.string.ok, null) .setNegativeButton(R.string.back, null) + + // Update dialog title based on wallet type and/or current cosigner + val keystores = arguments!!.getStringArrayList("keystores") + if (keystores != null && keystores.size != 0) { + builder.setTitle(multisigTitle(arguments!!)) + } else { + builder.setTitle(R.string.New_wallet) + } } override fun onPreExecute() { input = etInput.text.toString() } - override fun doInBackground(): String { + override fun doInBackground(): PyObject? { val name = arguments!!.getString("name")!! val password = arguments!!.getString("password")!! - onCreateWallet(name, password) - daemonModel.loadWallet(name, password) - return name + val ks = onCreateWallet(name, password) + + /** + * For multisig wallets, wait until all cosigners have been added, + * and then create and load the multisig wallet. + * + * Otherwise, load the created wallet. + */ + val keystores = updatedKeystores(arguments!!, ks) + if (keystores != null) { + val numCosigners = arguments!!.getInt("cosigners") + val numSignatures = arguments!!.getInt("signatures") + + if (keystores.size == numCosigners) { + daemonModel.commands.callAttr( + "create_multisig", name, password, + Kwarg("keystores", keystores.toArray()), + Kwarg("cosigners", numCosigners), + Kwarg("signatures", numSignatures) + ) + daemonModel.loadWallet(name, password) + } + } else { + daemonModel.loadWallet(name, password) + } + + return ks } - abstract fun onCreateWallet(name: String, password: String) + abstract fun onCreateWallet(name: String, password: String): PyObject? + + override fun onPostExecute(result: PyObject?) { + val keystores = updatedKeystores(arguments!!, result) + val name = arguments!!.getString("name") + + /** + * For multisig wallets, we need to first show the master key to the 1st cosigner, and + * then prompt for data for all other cosigners by calling the KeystoreDialog again. + */ + if (keystores != null) { + val currentCosigner = keystores.size + val numCosigners = arguments!!.getInt("cosigners") + + if (currentCosigner < numCosigners) { + val keystoreDialog = targetFragment as KeystoreDialog + val nextArguments = Bundle(arguments).apply { + putStringArrayList("keystores", keystores) + } + // For the first cosigner we show the master public key so they can share it. + if (currentCosigner == 1) { + val nextDialog = MasterPublicKeyDialog() + nextDialog.setArguments(nextArguments.apply { + val masterKey = result!!.callAttr("get", "xpub").toString() + putString("masterKey", masterKey) + }) + showDialog(keystoreDialog, nextDialog) + } else { + // Update dialog title and arguments for the next cosigner + keystoreDialog.updateArguments(nextArguments) + } + } else { // last cosigner done; finalize wallet + selectWallet(targetFragment!!, name) + } + } else { + // In a standard wallet, close the dialogs and open the newly created wallet. + selectWallet(targetFragment!!, name) + } + } - override fun onPostExecute(result: String) { - (targetFragment as NewWalletDialog1).dismiss() - daemonModel.commands.callAttr("select_wallet", result) + private fun selectWallet(targetFragment: Fragment, name: String?) { + closeDialogs(targetFragment) + daemonModel.commands.callAttr("select_wallet", name) (activity as MainActivity).updateDrawer() } } @@ -152,16 +306,19 @@ class NewWalletSeedDialog : NewWalletDialog2() { } } - override fun onCreateWallet(name: String, password: String) { + override fun onCreateWallet(name: String, password: String): PyObject? { try { if (derivation != null && !libBitcoin.callAttr("is_bip32_derivation", derivation).toBoolean()) { throw ToastException(R.string.Derivation_invalid) } - daemonModel.commands.callAttr( + + val multisig = arguments!!.containsKey("keystores") + return daemonModel.commands.callAttr( "create", name, password, Kwarg("seed", input), Kwarg("passphrase", passphrase), + Kwarg("multisig", multisig), Kwarg("bip39_derivation", derivation)) } catch (e: PyException) { if (e.message!!.startsWith("InvalidSeed")) { @@ -176,7 +333,7 @@ class NewWalletSeedDialog : NewWalletDialog2() { class NewWalletImportDialog : NewWalletDialog2() { override fun onBuildDialog(builder: AlertDialog.Builder) { super.onBuildDialog(builder) - builder.setNeutralButton(R.string.qr_code, null) + builder.setNeutralButton(R.string.scan_qr, null) } override fun onShowDialog() { @@ -185,7 +342,7 @@ class NewWalletImportDialog : NewWalletDialog2() { dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { scanQR(this) } } - override fun onCreateWallet(name: String, password: String) { + override fun onCreateWallet(name: String, password: String): PyObject? { var foundAddress = false var foundPrivkey = false for (word in input.split(Regex("\\s+"))) { @@ -204,7 +361,7 @@ class NewWalletImportDialog : NewWalletDialog2() { } } - if (foundAddress) { + return if (foundAddress) { if (foundPrivkey) { throw ToastException( R.string.cannot_specify_private_keys_and_addresses_in_the_same_wallet) @@ -220,29 +377,42 @@ class NewWalletImportDialog : NewWalletDialog2() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result != null && result.contents != null) { - val text = etInput.text - if (!text.isEmpty() && !text.endsWith("\n")) { - text.append("\n") - } - text.append(result.contents) - Selection.setSelection(text, text.length) + appendLine(etInput, result.contents) } else { super.onActivityResult(requestCode, resultCode, data) } } } +fun appendLine(et: EditText, str: String) { + val text = et.text + if (!text.isEmpty() && !text.endsWith("\n")) { + text.append("\n") + } + text.append(str) + Selection.setSelection(text, text.length) +} + class NewWalletImportMasterDialog : NewWalletDialog2() { override fun onBuildDialog(builder: AlertDialog.Builder) { super.onBuildDialog(builder) - builder.setNeutralButton(R.string.qr_code, null) + builder.setNeutralButton(R.string.scan_qr, null) } override fun onShowDialog() { super.onShowDialog() - tvPrompt.setText(getString(R.string.to_create_a_watching) + " " + - getString(R.string.to_create_a_spending)) + val keystores = arguments!!.getStringArrayList("keystores") + + val keyPrompt = if (keystores != null && keystores.size != 0) { + getString(R.string.please_enter_the_master_public_key_xpub) + " " + + getString(R.string.enter_their) + } else { + getString(R.string.to_create_a_watching) + " " + + getString(R.string.to_create_a_spending) + } + tvPrompt.setText(keyPrompt) + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { scanQR(this) } } @@ -256,10 +426,15 @@ class NewWalletImportMasterDialog : NewWalletDialog2() { } } - override fun onCreateWallet(name: String, password: String) { + override fun onCreateWallet(name: String, password: String): PyObject? { val key = input.trim() if (libKeystore.callAttr("is_bip32_key", key).toBoolean()) { - daemonModel.commands.callAttr("create", name, password, Kwarg("master", key)) + val multisig = arguments!!.containsKey("keystores") + return daemonModel.commands.callAttr( + "create", name, password, + Kwarg("master", key), + Kwarg("multisig", multisig) + ) } else { throw ToastException(R.string.please_specify) } @@ -298,9 +473,139 @@ fun setupSeedDialog(fragment: AlertDialogFragment) { } } +// Choose the number of multi-sig wallet cosigners +class CosignerDialog : AlertDialogFragment() { + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.Multi_signature) + .setView(R.layout.multisig_cosigners) + .setPositiveButton(R.string.next, null) + .setNegativeButton(R.string.cancel, null) + } + + val numCosigners: Int + get() = sbCosigners.progress + COSIGNER_OFFSET + + val numSignatures: Int + get() = sbSignatures.progress + SIGNATURE_OFFSET + + override fun onFirstShowDialog() { + super.onFirstShowDialog() + + with (sbCosigners) { + progress = 0 + } + + with (sbSignatures) { + progress = numCosigners - SIGNATURE_OFFSET + max = numCosigners - SIGNATURE_OFFSET + } + } + + override fun onShowDialog() { + super.onShowDialog() + updateUi() + + // Handle the total number of cosigners + with (sbCosigners) { + max = MAX_COSIGNERS - COSIGNER_OFFSET + + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + updateUi() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + + // Handle the number of required signatures + with (sbSignatures) { + setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + updateUi() + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + } + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + try { + val nextDialog = KeystoreDialog() + val nextArguments = Bundle(arguments) + nextArguments.putInt("cosigners", numCosigners) + nextArguments.putInt("signatures", numSignatures) + // The "keystores" argument contains keystore data for multiple cosigners + // in multisig wallets. It is used throughout the file to check if dealing + // with a multisig wallet and to get relevant cosigner data. + nextArguments.putStringArrayList("keystores", ArrayList()) + + nextDialog.setArguments(nextArguments) + showDialog(this, nextDialog) + } catch (e: ToastException) { + e.show() + } + } + } + + private fun updateUi() { + tvCosigners.text = getString(R.string.from_cosigners, numCosigners) + tvSignatures.text = getString(R.string.require_signatures, numSignatures) + sbSignatures.max = numCosigners - SIGNATURE_OFFSET + } +} + +/** + * View and copy the master public key of the (multisig) wallet. + */ +class MasterPublicKeyDialog : AlertDialogFragment() { + override fun onBuildDialog(builder: AlertDialog.Builder) { + builder.setView(R.layout.show_master_key) + .setPositiveButton(R.string.next, null) + .setNegativeButton(R.string.back, null) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fabCopyMasterKey.setOnClickListener { + copyToClipboard(walletMasterKey.text, R.string.Master_public_key) + } + } + + override fun onShowDialog() { + super.onShowDialog() + walletMasterKey.setText(arguments!!.getString("masterKey")) + walletMasterKey.setFocusable(false) + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + dismiss() + (targetFragment as KeystoreDialog).updateArguments(Bundle(arguments)) + } + } +} fun seedAdvice(seed: String): String { return app.getString(R.string.please_save, seed.split(" ").size) + " " + app.getString(R.string.this_seed_will) + " " + app.getString(R.string.never_disclose) } + +/** + * Returns the updated "keystores" array list for multisig wallets, used to check whether to + * finalize multisig wallet creation (or if it is a multisig wallet at all). + * In intermediary steps (adding non-final cosigners), the updated keystores will be stored into + * a dialog argument in onPostExecute(). + */ +fun updatedKeystores(arguments: Bundle, ks: PyObject?): ArrayList? { + val keystores = arguments.getStringArrayList("keystores") + if (keystores != null) { + val newKeystores = ArrayList(keystores) + if (ks != null) { + newKeystores.add(ks.toString()) + } + return newKeystores + } + return null +} \ No newline at end of file diff --git a/android/app/src/main/java/org/electroncash/electroncash3/QR.kt b/android/app/src/main/java/org/electroncash/electroncash3/QR.kt index c587226548b2..adcdd0e6b8ca 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/QR.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/QR.kt @@ -1,13 +1,14 @@ package org.electroncash.electroncash3 -import androidx.fragment.app.Fragment +import android.graphics.Bitmap +import android.view.View import android.widget.ImageView -import com.google.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.MultiFormatWriter +import androidx.fragment.app.Fragment import com.google.zxing.WriterException import com.google.zxing.integration.android.IntentIntegrator -import com.journeyapps.barcodescanner.BarcodeEncoder +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.google.zxing.qrcode.encoder.Encoder +import java.util.* fun scanQR(fragment: Fragment) { @@ -19,21 +20,47 @@ fun scanQR(fragment: Fragment) { } +private val qrListeners = WeakHashMap() + fun showQR(img: ImageView, text: String) { - // The layout already provides a margin of about 2 blocks, which is enough for all current - // scanners (https://qrworld.wordpress.com/2011/08/09/the-quiet-zone/). - val hints = mapOf(EncodeHintType.MARGIN to 0) - - val resolution = app.resources.getDimensionPixelSize(R.dimen.qr_resolution) - try { - val matrix = MultiFormatWriter().encode( - text, BarcodeFormat.QR_CODE, resolution, resolution, hints) - img.setImageBitmap(BarcodeEncoder().createBitmap(matrix)) - } catch (e: WriterException) { - if (e.message == "Data too big") { - img.setImageDrawable(null) - } else { - throw e + showQRNow(img, text) + + // View sizes aren't available in onStart, so install a layout listener. + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + showQRNow(img, text) + } + img.addOnLayoutChangeListener(listener) + val oldListener = qrListeners.put(img, listener) + if (oldListener != null) { + img.removeOnLayoutChangeListener(oldListener) + } +} + +private fun showQRNow(img: ImageView, text: String) { + val resolution = img.height // The layout XML should set this using R.dimen.qr_... + + if (resolution > 0) { + // The zxing renderer outputs an equal number of pixels per block, which can cause a + // lot of extra padding for large QRs. So we instead render at one pixel per block and + // scale up using nearest neighbor. + try { + val BLACK = 0xFF000000.toInt() + val WHITE = 0xFFFFFFFF.toInt() + val matrix = Encoder.encode(text, ErrorCorrectionLevel.L).matrix + val pixels = IntArray(matrix.width * matrix.height) { + if (matrix.get(it % matrix.width, it / matrix.width).toInt() == 1) + BLACK else WHITE + } + val smallBitmap = Bitmap.createBitmap( + pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888) + img.setImageBitmap( + Bitmap.createScaledBitmap(smallBitmap, resolution, resolution, false)) + } catch (e: WriterException) { + if (e.message == "Data too big") { + img.setImageDrawable(null) + } else { + throw e + } } } } \ No newline at end of file diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Send.kt b/android/app/src/main/java/org/electroncash/electroncash3/Send.kt index 616ed14e78fc..76ee1c7078f1 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Send.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Send.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.text.Editable import android.view.Menu import android.view.MenuItem +import android.view.View import android.widget.SeekBar import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -14,11 +15,14 @@ import androidx.lifecycle.ViewModel import com.chaquo.python.Kwarg import com.chaquo.python.PyException import com.chaquo.python.PyObject +import com.chaquo.python.PyObject.fromJava import com.google.zxing.integration.android.IntentIntegrator +import kotlinx.android.synthetic.main.load.* import kotlinx.android.synthetic.main.send.* val libPaymentRequest by lazy { libMod("paymentrequest") } +val libStorage by lazy { libMod("storage") } val MIN_FEE = 1 // sat/byte @@ -35,11 +39,40 @@ class SendDialog : TaskLauncherDialog() { } val model: Model by viewModels() + // This is currently used by the sweep private keys command. In the future it could also be + // used for coin selection. + val inputs by lazy { + val inputsStr = arguments?.getString("inputs") + if (inputsStr == null) null + else literalEval(inputsStr)!!.also { + for (i in it.asList()) { + val iMap = i.asMap() + iMap[fromJava("address")] = makeAddress(iMap[fromJava("address")].toString()) + } + } + } + + // The "unbroadcasted" flag controls whether the dialog opens as "Send" (false) or + // "Sign" (true). m-of-n multisig wallets where m >= 2 will also open the dialog + // as "Sign", because their transactions can't be broadcast after a single signature. val unbroadcasted by lazy { - arguments?.getBoolean("unbroadcasted", false) ?: false + if (arguments != null && arguments!!.containsKey("unbroadcasted")) { + arguments!!.getBoolean("unbroadcasted") + } else { + val multisigType = libStorage.callAttr("multisig_type", daemonModel.walletType) + ?.toJava(IntArray::class.java) + multisigType != null && multisigType[0] != 1 + } } + + val readOnly by lazy { + arguments != null && + (arguments!!.containsKey("txHex") || arguments!!.containsKey("sweepKeypairs")) + } + lateinit var amountBox: AmountBox var settingAmount = false // Prevent infinite recursion. + private lateinit var description: String init { // The SendDialog shouldn't be dismissed until the SendPasswordDialog succeeds. @@ -60,15 +93,17 @@ class SendDialog : TaskLauncherDialog() { builder.setTitle(R.string.send) .setPositiveButton(R.string.send, null) } else { - builder.setTitle(R.string.save_transaction) + builder.setTitle(R.string.sign_transaction) .setPositiveButton(R.string.sign, null) } builder.setView(R.layout.send) .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.qr_code, null) + .setNeutralButton(R.string.scan_qr, null) } override fun onShowDialog() { + super.onShowDialog() + etAddress.addAfterTextChangedListener { s: Editable -> val scheme = libNetworks.get("net")!!.get("CASHADDR_PREFIX")!!.toString() if (s.startsWith(scheme + ":")) { @@ -119,7 +154,36 @@ class SendDialog : TaskLauncherDialog() { } setFeeLabel() - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { onOK() } + // If this is the final signature, the user will be given a chance to set the + // description in the SignedTransactionDialog. + if (unbroadcasted) { + hideDescription(this) + } + + val txHex = arguments?.getString("txHex") + if (txHex != null) { + val tx = txFromHex(txHex) + model.tx.value = TxResult(tx) + setLoadedTransaction(tx) + } + + if (arguments?.getString("sweepKeypairs") != null) { + btnMax.isChecked = true + + // The inputs may be truncated to avoid exceeding the maximum transaction size, + // Display the input count so the user knows to sweep again in that situation. + dialog.setTitle(app.getQuantityString1(R.plurals.sweep_input, + inputs!!.asList().size)) + } + + if (readOnly) { + etAddress.isFocusable = false + (btnContacts as View).visibility = View.GONE + amountBox.isEditable = false + btnMax.isEnabled = false + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).visibility = View.GONE + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { scanQR(this) } model.tx.observe(this, Observer { onTx(it) }) } @@ -139,8 +203,10 @@ class SendDialog : TaskLauncherDialog() { get() = MIN_FEE + sbFee.progress fun refreshTx() { - model.tx.refresh(TxArgs(wallet, model.paymentRequest, etAddress.text.toString(), - amountBox.amount, btnMax.isChecked)) + if (arguments?.containsKey("txHex") != true) { + model.tx.refresh(TxArgs(wallet, model.paymentRequest, etAddress.text.toString(), + amountBox.amount, btnMax.isChecked, inputs)) + } } fun onTx(result: TxResult) { @@ -164,17 +230,20 @@ class SendDialog : TaskLauncherDialog() { } } - fun setFeeLabel(tx: PyObject? = null) { - var feeLabel = getString(R.string.sat_byte, feeSpb) - if (tx != null) { - val fee = tx.callAttr("get_fee").toLong() - feeLabel += " (${ltr(formatSatoshisAndUnit(fee))})" + fun setFeeLabel(tx: PyObject? = null): Int { + val fee = tx?.callAttr("get_fee")?.toInt() + val spb = if (fee != null) fee / tx.callAttr("estimated_size").toInt() + else feeSpb + var feeLabel = getString(R.string.sats_per, spb) + if (fee != null) { + feeLabel += " (${ltr(formatSatoshisAndUnit(fee.toLong()))})" } tvFeeLabel.setText(feeLabel) + return spb } class TxArgs(val wallet: PyObject, val pr: PyObject?, val addrStr: String, - val amount: Long?, val max: Boolean) { + val amount: Long?, val max: Boolean, val inputs: PyObject?) { fun invoke(): TxResult { var isDummy = false val outputs: PyObject @@ -196,11 +265,12 @@ class SendDialog : TaskLauncherDialog() { outputs = py.builtins.callAttr("list", arrayOf(output)) } - val inputs = wallet.callAttr("get_spendable_coins", null, daemonModel.config, + val inputs = this.inputs ?: + wallet.callAttr("get_spendable_coins", null,daemonModel.config, Kwarg("isInvoice", pr != null)) return try { TxResult(wallet.callAttr("make_unsigned_transaction", inputs, outputs, - daemonModel.config, Kwarg("sign_schnorr", true)), + daemonModel.config, Kwarg("sign_schnorr", signSchnorr())), isDummy) } catch (e: PyException) { TxResult(if (e.message!!.startsWith("NotEnoughFunds")) @@ -209,7 +279,8 @@ class SendDialog : TaskLauncherDialog() { } } - class TxResult(val tx: PyObject?, val isDummy: Boolean, val error: Throwable? = null) { + class TxResult(val tx: PyObject?, val isDummy: Boolean = false, + val error: Throwable? = null) { constructor(error: Throwable) : this(null, false, error) fun get() = tx ?: throw error!! } @@ -226,6 +297,10 @@ class SendDialog : TaskLauncherDialog() { fun onUri(uri: String) { try { + if (readOnly) { + throw ToastException(R.string.cannot_process) + } + val parsed: PyObject try { parsed = libWeb.callAttr("parse_URI", uri)!! @@ -240,7 +315,14 @@ class SendDialog : TaskLauncherDialog() { setPaymentRequest(null) parsed.callAttr("get", "address")?.let { etAddress.setText(it.toString()) } parsed.callAttr("get", "message")?.let { etDescription.setText(it.toString()) } - parsed.callAttr("get", "amount")?.let { amountBox.amount = it.toLong() } + parsed.callAttr("get", "amount")?.let { + try { + amountBox.amount = it.toLong() + } catch (e: PyException) { + throw if (e.message!!.startsWith("OverflowError")) ToastException(e) + else e + } + } amountBox.requestFocus() btnMax.isChecked = false } @@ -275,27 +357,72 @@ class SendDialog : TaskLauncherDialog() { } } - fun onOK() { - if (model.tx.isComplete()) { - onPostExecute(Unit) + /** + * Fill in the Send dialog with data from a loaded transaction. + */ + fun setLoadedTransaction(tx: PyObject) { + val spb = setFeeLabel(tx) + sbFee.setOnSeekBarChangeListener(null) // Avoid persisting to settings. + sbFee.progress = spb - MIN_FEE + sbFee.isEnabled = false + + // Try to guess which outputs are the intended recipients. Where possible, this should + // display the same values that were entered when the transaction was created. + val wallet = daemonModel.wallet!! + val outputs = tx.callAttr("outputs").asList() + var recipients = filterOutputs(outputs, wallet, "is_mine") + if (recipients.isEmpty()) { + // All outputs are mine. Try only receiving addresses. + recipients = filterOutputs(outputs, wallet, "is_change") + } + if (recipients.isEmpty()) { + // All outputs are change. + recipients = outputs + } + + // If there is only one recipient, their address will be displayed. + // Otherwise, this is a "pay to many" transaction. + if (recipients.size == 1) { + etAddress.setText(recipients[0].asList()[1].toString()) } else { - launchTask() + etAddress.setText(R.string.pay_to_many) } + setAmount(recipients.map { it.asList()[2].toLong() }.sum()) + } + + private fun filterOutputs(outputs: List, wallet: PyObject, methodName: String) = + outputs.filter { !wallet.callAttr(methodName, it.asList()[1]).toBoolean() } + + override fun onPreExecute() { + description = etDescription.text.toString() } override fun doInBackground() { model.tx.waitUntilComplete() + val keypairsStr = arguments?.getString("sweepKeypairs") + if (keypairsStr != null) { + val tx = model.tx.value!!.get() + tx.callAttr("sign", literalEval(keypairsStr)) + broadcastTransaction(wallet, tx, description) + } } override fun onPostExecute(result: Unit) { - try { - val txResult = model.tx.value!! - if (txResult.isDummy) throw ToastException(R.string.Invalid_address) - txResult.get() // May throw other ToastExceptions. - showDialog(this, SendPasswordDialog().apply { arguments = Bundle().apply { - putString("description", this@SendDialog.etDescription.text.toString()) - }}) - } catch (e: ToastException) { e.show() } + if (arguments != null && arguments!!.containsKey("sweepKeypairs")) { + toast(R.string.payment_sent) + closeDialogs(this) + } else { + try { + // Verify the transaction is valid before asking for a password. + val txResult = model.tx.value!! + if (txResult.isDummy) throw ToastException(R.string.Invalid_address) + txResult.get() // May throw ToastException. + + showDialog(this, SendPasswordDialog().apply { arguments = Bundle().apply { + putString("description", this@SendDialog.etDescription.text.toString()) + }}) + } catch (e: ToastException) { e.show() } + } } } @@ -355,35 +482,33 @@ class SendContactsDialog : MenuDialog() { class SendPasswordDialog : PasswordDialog() { val sendDialog by lazy { targetFragment as SendDialog } - val tx by lazy { sendDialog.model.tx.value!!.get() } + val tx: PyObject by lazy { sendDialog.model.tx.value!!.get() } override fun onPassword(password: String) { val wallet = sendDialog.wallet wallet.callAttr("sign_transaction", tx, password) if (!sendDialog.unbroadcasted) { - if (!daemonModel.isConnected()) { - throw ToastException(R.string.not_connected) - } val pr = sendDialog.model.paymentRequest - val result = if (pr != null) { - checkExpired(pr) - val refundAddr = wallet.callAttr("get_receiving_addresses").asList().get(0) - pr.callAttr("send_payment", tx.toString(), refundAddr) - } else { - daemonModel.network.callAttr("broadcast_transaction", tx) - } - checkBroadcastResult(result) - setDescription(wallet, tx.callAttr("txid").toString(), - arguments!!.getString("description")!!) + val broadcastFunc: ((PyObject) -> PyObject)? = + if (pr == null) null + else { tx -> + checkExpired(pr) + val refundAddr = wallet.callAttr("get_receiving_addresses").asList().get(0) + pr.callAttr("send_payment", tx.toString(), refundAddr) + } + broadcastTransaction(wallet, tx, arguments!!.getString("description")!!, + broadcastFunc) } } override fun onPostExecute(result: Unit) { - sendDialog.dismiss() + closeDialogs(sendDialog) if (!sendDialog.unbroadcasted) { toast(R.string.payment_sent, Toast.LENGTH_SHORT) } else { - copyToClipboard(tx.toString(), R.string.signed_transaction) + showDialog(this, SignedTransactionDialog().apply { arguments = Bundle().apply { + putString("txHex", tx.toString()) + }}) } } } @@ -396,11 +521,23 @@ private fun checkExpired(pr: PyObject) { } -fun checkBroadcastResult(result: PyObject) { - val success = result.asList().get(0).toBoolean() - if (!success) { +fun broadcastTransaction(wallet: PyObject, tx: PyObject, description: String, + broadcastFunc: ((PyObject) -> PyObject)? = null) { + daemonModel.assertConnected() + val result = if (broadcastFunc != null) { + broadcastFunc(tx) + } else { + daemonModel.network.callAttr("broadcast_transaction", tx) + } + if (!result.asList().get(0).toBoolean()) { var message = result.asList().get(1).toString() message = message.replace(Regex("^error: (.*)"), "$1") throw ToastException(message) } + + setDescription(wallet, tx.callAttr("txid").toString(), description) } + + +fun literalEval(str: String): PyObject? = + py.getModule("ast").callAttr("literal_eval", str) diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Transactions.kt b/android/app/src/main/java/org/electroncash/electroncash3/Transactions.kt index eb1dd87ab717..ef3bd93321ac 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Transactions.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Transactions.kt @@ -64,7 +64,7 @@ class TransactionModel(wallet: PyObject, val txHistory: PyObject) : ListItemMode val confirmations = get("conf")!!.toInt() when { confirmations <= 0 -> app.getString(R.string.Unconfirmed) - else -> app.resources.getQuantityString(R.plurals.confirmation, + else -> app.resources.getQuantityString(R.plurals.conf_confirmation, confirmations, confirmations) } } @@ -113,7 +113,7 @@ class TransactionDialog : DetailDialog() { } else { val feeSpb = (fee.toDouble() / size.toDouble()).roundToInt() tvFee.text = String.format("%s (%s)", - getString(R.string.sat_byte, feeSpb), + getString(R.string.sats_per, feeSpb), ltr(formatSatoshisAndUnit(fee))) } } diff --git a/android/app/src/main/java/org/electroncash/electroncash3/Util.kt b/android/app/src/main/java/org/electroncash/electroncash3/Util.kt index 0c6ea176d965..47c8d6f7559f 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Util.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Util.kt @@ -63,7 +63,12 @@ fun toSatoshis(s: String) : Long { val unit = Math.pow(10.0, unitPlaces.toDouble()) try { // toDouble accepts only the English number format: see comment above. - return Math.round(s.toDouble() * unit) + val result = Math.round(s.toDouble() * unit) + return if (result == Long.MAX_VALUE) { + throw ToastException(R.string.Invalid_amount) + } else { + result + } } catch (e: NumberFormatException) { throw ToastException(R.string.Invalid_amount) } @@ -158,7 +163,7 @@ fun copyToClipboard(text: CharSequence, what: Int? = null) { @Suppress("DEPRECATION") (getSystemService(ClipboardManager::class)).text = text val message = if (what == null) app.getString(R.string.text_copied) - else app.getString(R.string._s_copied, app.getString(what)) + else app.getString(R.string.___copied_to, app.getString(what)) toast(message, Toast.LENGTH_SHORT) } @@ -218,8 +223,9 @@ class BoundViewHolder(val binding: ViewDataBinding) } -class MenuAdapter(context: Context, val menu: Menu) - : ArrayAdapter(context, android.R.layout.simple_spinner_item, menuToList(menu)) { +open class SimpleArrayAdapter(context: Context, objects: List) + : ArrayAdapter(context, android.R.layout.simple_spinner_item, objects) { + init { if (context === app) { // This resulted in white-on-white text on older API levels (e.g. 18). @@ -228,6 +234,10 @@ class MenuAdapter(context: Context, val menu: Menu) } setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) } +} + +class MenuAdapter(context: Context, val menu: Menu) + : SimpleArrayAdapter(context, menuToList(menu)) { constructor(context: Context, menuId: Int) : this(context, inflateMenu(menuId)) @@ -250,3 +260,23 @@ private fun menuToList(menu: Menu): List { } return result } + + +fun baseEncode(hex: String, base: Int): String { + val bytes = py.builtins.get("bytes")!!.callAttr("fromhex", hex) + return libBitcoin.callAttr("base_encode", bytes, base).toString() +} + +fun baseDecode(s: String, base: Int): String { + return libBitcoin.callAttr("base_decode", s, null, base) + .callAttr("hex").toString() +} + + +/** + * Decide whether to use Schnorr signatures. + * Schnorr signing is supported by standard and imported private key wallets. + */ +fun signSchnorr(): Boolean { + return daemonModel.walletType in listOf("standard", "imported_privkey") +} \ No newline at end of file diff --git a/android/app/src/main/python/electroncash_gui/android/console.py b/android/app/src/main/python/electroncash_gui/android/console.py index 3ab067493d67..80918851dfa7 100644 --- a/android/app/src/main/python/electroncash_gui/android/console.py +++ b/android/app/src/main/python/electroncash_gui/android/console.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division, print_function +import ast from code import InteractiveConsole import os from os.path import dirname, exists, join, split @@ -11,7 +12,7 @@ from electroncash.i18n import _ from electroncash.storage import WalletStorage from electroncash.wallet import (ImportedAddressWallet, ImportedPrivkeyWallet, Standard_Wallet, - Wallet) + Wallet, Multisig_Wallet) CALLBACKS = ["banner", "blockchain_updated", "fee", "interfaces", "new_transaction", @@ -135,7 +136,7 @@ def close_wallet(self, name=None): self.daemon.stop_wallet(self._wallet_path(name)) def create(self, name, password, seed=None, passphrase="", bip39_derivation=None, - master=None, addresses=None, privkeys=None): + master=None, addresses=None, privkeys=None, multisig=False): """Create or restore a new wallet""" path = self._wallet_path(name) if exists(path): @@ -158,9 +159,31 @@ def create(self, name, password, seed=None, passphrase="", bip39_derivation=None print("Your wallet generation seed is:\n\"%s\"" % seed) ks = keystore.from_seed(seed, passphrase) - storage.put('keystore', ks.dump()) - wallet = Standard_Wallet(storage) + if not multisig: + storage.put('keystore', ks.dump()) + wallet = Standard_Wallet(storage) + else: + # For multisig wallets, we do not immediately create a wallet storage file. + # Instead, we just get the keystore; create_multisig() handles wallet storage + # later, once all cosigners are added. + return ks.dump() + + wallet.update_password(None, password, encrypt=True) + + def create_multisig(self, name, password, keystores=None, cosigners=None, signatures=None): + """Create or restore a new wallet""" + path = self._wallet_path(name) + if exists(path): + raise FileExistsError(path) + storage = WalletStorage(path) + + # Multisig wallet type + storage.put("wallet_type", "%dof%d" % (signatures, cosigners)) + for i, k in enumerate(keystores, start=1): + storage.put('x%d/' % i, ast.literal_eval(k)) + storage.write() + wallet = Multisig_Wallet(storage) wallet.update_password(None, password, encrypt=True) # END commands from the argparse interface. diff --git a/android/app/src/main/python/electroncash_gui/android/strings.py b/android/app/src/main/python/electroncash_gui/android/strings.py index ce95c76e93d2..70d8bd16bfa7 100644 --- a/android/app/src/main/python/electroncash_gui/android/strings.py +++ b/android/app/src/main/python/electroncash_gui/android/strings.py @@ -2,32 +2,34 @@ # in the Electron Cash repository. Some of them only differ in capitalization or punctuation: # see https://medium.com/@jsaito/making-a-case-for-letter-case-19d09f653c98 # -# If you change this file, you'll need to rebuild the strings.xml files by following the -# instructions in android/README.md. -# # Please keep the strings in alphabetical order. # This file is never actually imported, but keep syntax checkers happy. from gettext import gettext as _, ngettext ngettext("%d address", "%d addresses", 1) +_("(%1$d of %2$d)") _("Are you sure you want to delete your wallet \'%s\'?") _("BIP39 seed") _("Block explorer") +_("%s bytes") +_("Cannot process a URI while this dialog is open.") _("Cannot specify private keys and addresses in the same wallet.") _("Change password") _("Close wallet") _("Confirm password") _("Console") -_("Copyright © 2017-2021 Electron Cash LLC and the Electron Cash developers.") +_("Copyright © 2017-2022 Electron Cash LLC and the Electron Cash developers.") _("Current password") +_("Cosigner %d") _("Delete wallet") _("Derivation invalid") _("Disconnect") _("Do you want to close this wallet?") _("Enter password") _("Export wallet") -_("Filenames cannot contain the '/' character. Please enter a different filename to proceed.") +_("Filename is too long") +_("Filenames cannot contain the '/' character") _("For support, please visit us on " "GitHub or on Telegram.") _("ID") @@ -35,27 +37,29 @@ _("Invalid address") _("Load transaction") _("Made with Chaquopy, the Python SDK for Android.") +_("Master public key") +_("Master public keys") _("New password") _("New wallet") _("No wallet is open.") _("No wallet") _("Not a valid address or private key: '%s'") _("Passphrase") +_("Paste or scan a transaction in hex format:") _("Press the menu button above to open or create one.") _("Rename wallet") _("Request") _("Restore from seed") -_("Save transaction") +_("Scan QR") +_("Sign transaction") _("Show seed") _("Size") _("Signed transaction") -_("The string you entered has been broadcast. Please check your transactions for confirmation.") +ngettext("Sweep %d input", "Sweep %d inputs", 1) _("Transaction not found") _("%1$d tx (%2$d unverified)") -_("Type, paste, or scan a valid signed transaction in hex format below:") _("Use a master key") -_("Wallet name is too long") -_("Wallet names cannot contain the '/' character. Please enter a different wallet name to proceed.") +_("Wallet information") _("Wallet exported successfully") _("Wallet renamed successfully") _("Wallet seed") diff --git a/android/app/src/main/res/layout/address_detail.xml b/android/app/src/main/res/layout/address_detail.xml index 1c6030f7b1b8..d764cf107ff0 100644 --- a/android/app/src/main/res/layout/address_detail.xml +++ b/android/app/src/main/res/layout/address_detail.xml @@ -64,8 +64,8 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/load.xml b/android/app/src/main/res/layout/load.xml index 6a4926b0c724..f82f3e999889 100644 --- a/android/app/src/main/res/layout/load.xml +++ b/android/app/src/main/res/layout/load.xml @@ -14,29 +14,28 @@ android:id="@+id/textView4" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginEnd="24dp" - android:layout_marginLeft="24dp" - android:layout_marginRight="24dp" android:layout_marginStart="24dp" android:layout_marginTop="8dp" - android:text="@string/type_paste" + android:layout_marginEnd="24dp" + android:text="@string/paste_or" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"/> + app:layout_constraintTop_toTopOf="parent" /> - + + @@ -48,8 +47,39 @@ app:layout_constraintBottom_toBottomOf="@+id/etTransaction" app:layout_constraintEnd_toEndOf="@+id/textView4" app:layout_constraintTop_toTopOf="@+id/etTransaction" - app:srcCompat="@drawable/ic_paste_24dp"/> + app:srcCompat="@drawable/ic_paste_24dp" /> + + + + + diff --git a/android/app/src/main/res/layout/multisig_cosigners.xml b/android/app/src/main/res/layout/multisig_cosigners.xml new file mode 100644 index 000000000000..404a28a4ff31 --- /dev/null +++ b/android/app/src/main/res/layout/multisig_cosigners.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/request_detail.xml b/android/app/src/main/res/layout/request_detail.xml index cb93ce70f135..a9740437cd79 100644 --- a/android/app/src/main/res/layout/request_detail.xml +++ b/android/app/src/main/res/layout/request_detail.xml @@ -51,8 +51,8 @@ diff --git a/android/app/src/main/res/layout/show_master_key.xml b/android/app/src/main/res/layout/show_master_key.xml new file mode 100644 index 000000000000..2e464ac65dfb --- /dev/null +++ b/android/app/src/main/res/layout/show_master_key.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/signed_transaction.xml b/android/app/src/main/res/layout/signed_transaction.xml new file mode 100644 index 000000000000..a1e302ff2481 --- /dev/null +++ b/android/app/src/main/res/layout/signed_transaction.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/sweep.xml b/android/app/src/main/res/layout/sweep.xml new file mode 100644 index 000000000000..d7ad590a3d7b --- /dev/null +++ b/android/app/src/main/res/layout/sweep.xml @@ -0,0 +1,34 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/wallet_information.xml b/android/app/src/main/res/layout/wallet_information.xml new file mode 100644 index 000000000000..2fc8ce75292d --- /dev/null +++ b/android/app/src/main/res/layout/wallet_information.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/wallet_new.xml b/android/app/src/main/res/layout/wallet_new.xml index c32854d7da59..dcedec8cbaf8 100644 --- a/android/app/src/main/res/layout/wallet_new.xml +++ b/android/app/src/main/res/layout/wallet_new.xml @@ -10,25 +10,23 @@ android:layout_height="wrap_content"> + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="@+id/textView5" + app:layout_constraintTop_toBottomOf="@+id/textView5" /> + app:layout_constraintStart_toStartOf="@+id/spnWalletType" + app:layout_constraintTop_toBottomOf="@+id/spnWalletType" /> + app:constraint_referenced_ids="textView6,textView7" + tools:layout_editor_absoluteY="266dp" /> \ No newline at end of file diff --git a/android/app/src/main/res/menu/cosigner_type.xml b/android/app/src/main/res/menu/cosigner_type.xml new file mode 100644 index 000000000000..82f574af6cf4 --- /dev/null +++ b/android/app/src/main/res/menu/cosigner_type.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/wallet.xml b/android/app/src/main/res/menu/wallet.xml index 1d4c2c975cba..5debe7ee03fa 100644 --- a/android/app/src/main/res/menu/wallet.xml +++ b/android/app/src/main/res/menu/wallet.xml @@ -1,41 +1,35 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/android/app/src/main/res/menu/wallet_kind.xml b/android/app/src/main/res/menu/wallet_kind.xml new file mode 100644 index 000000000000..cdf6587da714 --- /dev/null +++ b/android/app/src/main/res/menu/wallet_kind.xml @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/wallet_type.xml b/android/app/src/main/res/menu/wallet_type.xml index c1866d832c82..e43941dfbeaf 100644 --- a/android/app/src/main/res/menu/wallet_type.xml +++ b/android/app/src/main/res/menu/wallet_type.xml @@ -7,9 +7,6 @@ - - diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml index 7cdbf7667e74..20b9b9912c4f 100644 --- a/android/app/src/main/res/values/dimens.xml +++ b/android/app/src/main/res/values/dimens.xml @@ -9,6 +9,7 @@ 0dp 0dp - 150dp + 150dp + 240dp diff --git a/android/build.gradle b/android/build.gradle index b8de1c111736..89d143c98c9c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,7 +4,7 @@ buildscript { ext.kotlinVersion = '1.3.41' repositories { google() - jcenter() + mavenCentral() maven { // This repository is for Electron Cash only: other apps should use // https://chaquo.com/maven . @@ -12,7 +12,7 @@ buildscript { } } dependencies { - classpath "com.android.tools.build:gradle:3.5.0" + classpath "com.android.tools.build:gradle:4.1.2" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // This version is for Electron Cash only. Other apps should use one of the versions @@ -24,7 +24,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 0b36bab0e40e..b5fbe5fdba65 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/android/app/setup.cfg b/android/setup.cfg similarity index 100% rename from android/app/setup.cfg rename to android/setup.cfg diff --git a/contrib/base.sh b/contrib/base.sh index 4705a6b48870..6066b80b950f 100644 --- a/contrib/base.sh +++ b/contrib/base.sh @@ -194,8 +194,8 @@ export PYTHONHASHSEED=22 export SOURCE_DATE_EPOCH=1530212462 # Note, when upgrading Python, check the Windows python.exe embedded manifest for changes. # If the manifest changed, contrib/build-wine/manifest.xml needs to be updated. -export PYTHON_VERSION=3.8.7 # Windows, OSX & Linux AppImage use this to determine what to download/build -export PYTHON_SRC_TARBALL_HASH="ddcc1df16bb5b87aa42ec5d20a5b902f2d088caa269b28e01590f97a798ec50a" # If you change PYTHON_VERSION above, update this by downloading the tarball manually and doing a sha256sum on it. +export PYTHON_VERSION=3.8.9 # Windows, OSX & Linux AppImage use this to determine what to download/build +export PYTHON_SRC_TARBALL_HASH="5e391f3ec45da2954419cab0beaefd8be38895ea5ce33577c3ec14940c4b9572" # If you change PYTHON_VERSION above, update this by downloading the tarball manually and doing a sha256sum on it. export DEFAULT_GIT_REPO=https://github.com/Electron-Cash/Electron-Cash if [ -z "$GIT_REPO" ] ; then # If no override from env is present, use default. Support for overrides diff --git a/contrib/build-linux/appimage/Dockerfile_ub1804 b/contrib/build-linux/appimage/Dockerfile_ub1804 index 951072893d65..11234255db60 100644 --- a/contrib/build-linux/appimage/Dockerfile_ub1804 +++ b/contrib/build-linux/appimage/Dockerfile_ub1804 @@ -10,7 +10,7 @@ RUN echo deb ${UBUNTU_MIRROR} bionic main restricted universe multiverse > /etc/ echo deb ${UBUNTU_MIRROR} bionic-security main restricted universe multiverse >> /etc/apt/sources.list && \ apt-get update -q && \ apt-get install -qy \ - git=1:2.17.1-1ubuntu0.8 \ + git=1:2.17.1-1ubuntu0.9 \ wget=1.19.4-1ubuntu2.2 \ make=4.1-9.1ubuntu1 \ autotools-dev=20180224.1 \ @@ -21,22 +21,35 @@ RUN echo deb ${UBUNTU_MIRROR} bionic main restricted universe multiverse > /etc/ libncurses5-dev=6.1-1ubuntu1.18.04 \ libsqlite3-dev=3.22.0-1ubuntu0.4 \ libusb-1.0-0-dev=2:1.0.21-2 \ - libudev-dev=237-3ubuntu10.50 \ + libudev-dev=237-3ubuntu10.53 \ gettext=0.19.8.1-6ubuntu0.3 \ pkg-config=0.29.1-0ubuntu2 \ libdbus-1-3=1.12.2-1ubuntu1.2 \ libpcsclite-dev=1.8.23-1 \ swig=3.0.12-1 \ libxkbcommon-x11-0=0.8.2-1~ubuntu18.04.1 \ + libxcb1=1.13-2~ubuntu18.04 \ + libxcb-icccm4=0.4.1-1ubuntu1 \ + libxcb-image0=0.4.0-1build1 \ + libxcb-keysyms1=0.4.0-1 \ + libxcb-randr0=1.13-2~ubuntu18.04 \ + libxcb-render-util0=0.3.9-1 \ + libxcb-render0=1.13-2~ubuntu18.04 \ + libxcb-shape0=1.13-2~ubuntu18.04 \ + libxcb-shm0=1.13-2~ubuntu18.04 \ + libxcb-sync1=1.13-2~ubuntu18.04 \ libxcb-util1=0.4.0-0ubuntu3 \ + libxcb-xfixes0=1.13-2~ubuntu18.04 \ libxcb-xinerama0=1.13-2~ubuntu18.04 \ + libxcb-xkb1=1.13-2~ubuntu18.04 \ + libx11-xcb1=2:1.6.4-3ubuntu0.4 \ autopoint=0.19.8.1-6ubuntu0.3 \ zlib1g-dev=1:1.2.11.dfsg-0ubuntu2 \ libfreetype6=2.8.1-2ubuntu2.1 \ libfontconfig1=2.12.6-0ubuntu2 \ - libssl-dev=1.1.1-1ubuntu2.1~18.04.9 \ - rustc=1.47.0+dfsg1+llvm-1ubuntu1~18.04.1 \ + libssl-dev=1.1.1-1ubuntu2.1~18.04.14 \ + rustc=1.53.0+dfsg1+llvm-4ubuntu1~18.04.1 \ && \ rm -rf /var/lib/apt/lists/* && \ apt-get autoremove -y && \ - apt-get clean \ No newline at end of file + apt-get clean diff --git a/contrib/build-linux/appimage/README.md b/contrib/build-linux/appimage/README.md index b18cb677743d..1a0745962051 100644 --- a/contrib/build-linux/appimage/README.md +++ b/contrib/build-linux/appimage/README.md @@ -11,21 +11,19 @@ folder. 1. Install Docker (Ubuntu instructions -- other platforms vary) ``` - $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - - $ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" - $ sudo apt-get update - $ sudo apt-get install -y docker-ce + $ sudo apt update + $ sudo apt install -y docker.io ``` -2. Build binary +2. Make sure your current user account is in the `docker` group (edit `/etc/groups`, log out, log back in). + +3. Build binary ``` - $ sudo contrib/build-linux/appimage/build.sh REVISION_TAG_OR_BRANCH_OR_COMMIT_TAG + $ contrib/build-linux/appimage/build.sh REVISION_TAG_OR_BRANCH_OR_COMMIT_TAG ``` - _Note:_ If you are using a MacOS host, run the above **without** `sudo`. - -3. The generated .AppImage binary is in `./dist`. +4. The generated .AppImage binary is in `./dist`. ## FAQ diff --git a/contrib/build-linux/appimage/_build.sh b/contrib/build-linux/appimage/_build.sh index 0fe23b3ad358..36f20f700a80 100755 --- a/contrib/build-linux/appimage/_build.sh +++ b/contrib/build-linux/appimage/_build.sh @@ -109,6 +109,7 @@ info "Preparing electrum-locale" info "Installing Electron Cash and its dependencies" mkdir -p "$CACHEDIR/pip_cache" +"$python" -m pip install --no-deps --no-warn-script-location --no-binary :all: --cache-dir "$CACHEDIR/pip_cache" -r "$CONTRIB/deterministic-build/requirements-pip.txt" "$python" -m pip install --no-deps --no-warn-script-location --no-binary :all: --cache-dir "$CACHEDIR/pip_cache" -r "$CONTRIB/deterministic-build/requirements.txt" "$python" -m pip install --no-deps --no-warn-script-location --no-binary :all: --only-binary pyqt5 --cache-dir "$CACHEDIR/pip_cache" -r "$CONTRIB/deterministic-build/requirements-binaries.txt" "$python" -m pip install --no-deps --no-warn-script-location --no-binary :all: --cache-dir "$CACHEDIR/pip_cache" -r "$CONTRIB/deterministic-build/requirements-hw.txt" @@ -153,6 +154,9 @@ cp -fp /usr/lib/x86_64-linux-gnu/libusb-1.0.so "$APPDIR"/usr/lib/x86_64-linux-gn # some distros lack libxkbcommon-x11 cp -f /usr/lib/x86_64-linux-gnu/libxkbcommon-x11.so.0 "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxkbcommon-x11" +# some distros lack some libxcb libraries (see #2189, #2196) +cp -f /usr/lib/x86_64-linux-gnu/libxcb* "$APPDIR"/usr/lib/x86_64-linux-gnu || fail "Could not copy libxkcb" + info "Stripping binaries of debug symbols" # "-R .note.gnu.build-id" also strips the build id # "-R .comment" also strips the GCC version information @@ -206,8 +210,11 @@ find -exec touch -h -d '2000-11-11T11:11:11+00:00' {} + info "Creating the AppImage" ( cd "$BUILDDIR" - chmod +x "$CACHEDIR/appimagetool" - "$CACHEDIR/appimagetool" --appimage-extract + cp "$CACHEDIR/appimagetool" "$CACHEDIR/appimagetool_copy" + # zero out "appimage" magic bytes, as on some systems they confuse the linker + sed -i 's|AI\x02|\x00\x00\x00|' "$CACHEDIR/appimagetool_copy" + chmod +x "$CACHEDIR/appimagetool_copy" + "$CACHEDIR/appimagetool_copy" --appimage-extract # We build a small wrapper for mksquashfs that removes the -mkfs-fixed-time option # that mksquashfs from squashfskit does not support. It is not needed for squashfskit. cat > ./squashfs-root/usr/lib/appimagekit/mksquashfs << EOF diff --git a/contrib/build-wine/README.md b/contrib/build-wine/README.md index 7f706a0d1ebc..ceb25b6b87bb 100644 --- a/contrib/build-wine/README.md +++ b/contrib/build-wine/README.md @@ -14,9 +14,8 @@ repository):: Where BRANCH_OR_TAG above is a git branch or tag you wish to build. Note: If on a Linux host, the above script may ask you for your password as -docker requires commands be run via sudo. Make sure you are in the /etc/sudoers -file. On a macOS host, this is not the case and docker can be run as a normal -user. +docker requires commands be run as either `root` or a user in the `docker` group. +Make sure you are in the `docker` group (edit `/etc/groups`, log out, log back in). +On a macOS host, this is not the case and docker can be run as a normal user. The built .exe files will be placed in: `dist/` - diff --git a/contrib/build-wine/_build.sh b/contrib/build-wine/_build.sh index f100e9f2c8ad..5b895953fa4a 100755 --- a/contrib/build-wine/_build.sh +++ b/contrib/build-wine/_build.sh @@ -89,19 +89,37 @@ prepare_wine() { "$here"/pgp/7ed10b6531d7c8e1bc296021fc624643487034e5.asc \ || fail "Failed to import Python release signing keys" - info "Installing Python ..." # Install Python - for msifile in core dev exe lib pip tools; do - info "Installing $msifile..." + info "Installing Python ..." + msifiles="core dev exe lib pip tools" + + for msifile in $msifiles ; do + info "Downloading $msifile..." wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi" wget "https://www.python.org/ftp/python/$PYTHON_VERSION/win32/${msifile}.msi.asc" verify_signature "${msifile}.msi.asc" $KEYRING_PYTHON_DEV + done + + $SHA256_PROG -c - << EOF +934eda542020cb5ba6de16f5c150e7571a25dd3cb9f3832eb8b2cb54c7331aa6 core.msi +58e517f0bb55fbf9ba1d9ec6a17a5420959469001ec1945c6c56dbbe566d3056 dev.msi +34594d5522b0939b50edfdb01a980589f559fc659ce1f20288d297dda0905630 exe.msi +fcd8bd0ead0a67906f39e99e557d66e71bc6810bd4916d03c2c5341ff95992bf lib.msi +8d8159fdb9f42b81e9206bf7be0c2b034b3df717d58cfb484c0b3fb8ec3cab6a pip.msi +ca8ef45591b07e7dbcc9b4b7b1c4d6528d3f0349fdf6779c71312bd9c3187801 tools.msi +EOF + test $? -eq 0 || fail "Failed to verify Python checksums" + + for msifile in $msifiles ; do + info "Installing $msifile..." wine msiexec /i "${msifile}.msi" /qn TARGETDIR=$PYHOME || fail "Failed to install Python component: ${msifile}" done - # The below requirements-build-wine.txt uses hashed packages that we + # The below requirement files use hashed packages that we # need for pyinstaller and other parts of the build. Using a hashed # requirements file hardens the build against dependency attacks. + info "Installing pip from requirements-pip.txt ..." + $PYTHON -m pip install --no-deps --no-warn-script-location -r $here/../deterministic-build/requirements-pip.txt || fail "Failed to install pip" info "Installing build requirements from requirements-build-wine.txt ..." $PYTHON -m pip install --no-deps --no-warn-script-location -r $here/../deterministic-build/requirements-build-wine.txt || fail "Failed to install build requirements" diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index d6255715022b..7fc153048d52 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -54,11 +54,9 @@ datas = [ (home+'electroncash/servers_testnet.json', 'electroncash'), (home+'electroncash/servers_testnet4.json', 'electroncash'), (home+'electroncash/servers_scalenet.json', 'electroncash'), - (home+'electroncash/servers_taxcoin.json', 'electroncash'), (home+'electroncash/wordlist/english.txt', 'electroncash/wordlist'), (home+'electroncash/locale', 'electroncash/locale'), (home+'electroncash_gui/qt/data/ecsupplemental_win.ttf', 'electroncash_gui/qt/data'), - (home+'electroncash_gui/qt/data/ard_mone.mp3', 'electroncash_gui/qt/data'), (home+'electroncash_plugins', 'electroncash_plugins'), ] datas += collect_data_files('trezorlib') diff --git a/contrib/build-wine/docker/Dockerfile b/contrib/build-wine/docker/Dockerfile index 868c5d4acb80..ad1506efa298 100644 --- a/contrib/build-wine/docker/Dockerfile +++ b/contrib/build-wine/docker/Dockerfile @@ -23,7 +23,8 @@ RUN echo deb ${UBUNTU_MIRROR} bionic main restricted universe multiverse > /etc/ apt-get update -q && \ apt-get install -qy \ gnupg2=2.2.4-1ubuntu1.3 \ - software-properties-common=0.96.24.32.14 && \ + software-properties-common=0.96.24.32.14 \ + python3-software-properties=0.96.24.32.14 && \ echo "78b185fabdb323971d13bd329fefc8038e08559aa51c4996de18db0639a51df6 /tmp/winehq.key" | sha256sum -c - && \ apt-key add /tmp/winehq.key && \ echo "6e4ab6a3731a1f66dbdbe036968ccea64da0c423d312e35b9f8209bb1c82a0a7 /tmp/opensuse.key" | sha256sum -c - && \ @@ -33,7 +34,7 @@ RUN echo deb ${UBUNTU_MIRROR} bionic main restricted universe multiverse > /etc/ apt-get update -q && \ apt-get install -qy \ wget=1.19.4-1ubuntu2.2 \ - git=1:2.17.1-1ubuntu0.8 \ + git=1:2.17.1-1ubuntu0.9 \ p7zip-full=16.02+dfsg-6 \ make=4.1-9.1ubuntu1 \ autotools-dev=20180224.1 \ diff --git a/contrib/build-wine/electron-cash.nsi b/contrib/build-wine/electron-cash.nsi index 0adcb46892c8..7c8b7969ca85 100644 --- a/contrib/build-wine/electron-cash.nsi +++ b/contrib/build-wine/electron-cash.nsi @@ -68,7 +68,7 @@ VIAddVersionKey ProductName "${PRODUCT_NAME} Installer" VIAddVersionKey Comments "The installer for ${PRODUCT_NAME}" VIAddVersionKey CompanyName "${PRODUCT_NAME}" - VIAddVersionKey LegalCopyright "2013-2021 ${PRODUCT_PUBLISHER} and Electrum Technologies GmbH" + VIAddVersionKey LegalCopyright "2013-2022 ${PRODUCT_PUBLISHER} and Electrum Technologies GmbH" VIAddVersionKey FileDescription "${PRODUCT_NAME} Installer" VIAddVersionKey FileVersion ${PRODUCT_VERSION} VIAddVersionKey ProductVersion ${PRODUCT_VERSION} @@ -102,6 +102,31 @@ !insertmacro MUI_LANGUAGE "English" +;-------------------------------- +;Functions + +!macro CreateEnsureNotRunning prefix operation + +Function ${prefix}EnsureNotRunning + Pop $R0 + IfFileExists "$R0\${INTERNAL_NAME}.exe" 0 noexe + ; Check if we can append to the .exe file. If we can't that means it is still running. + retryopen: + FileOpen $0 "$R0\${INTERNAL_NAME}.exe" a + IfErrors 0 closeexe + MessageBox MB_RETRYCANCEL "Can not ${operation} because ${PRODUCT_NAME} is still running. Close it and retry." /SD IDCANCEL IDRETRY retryopen + Abort + closeexe: + FileClose $0 + noexe: +FunctionEnd + +!macroend + +; The function has to be created twice, once for the installer and once for the uninstaller +!insertmacro CreateEnsureNotRunning "" "install" +!insertmacro CreateEnsureNotRunning "un." "uninstall" + ;-------------------------------- ;Installer Sections @@ -109,12 +134,23 @@ Function .onInit !insertmacro UNINSTALL.LOG_PREPARE_INSTALL + ; Check if already installed and ensure the process is not running if it is + ReadRegStr $R0 ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "UninstallDirectory" + IfErrors noinstdir 0 + Push $R0 + Call EnsureNotRunning + noinstdir: + ClearErrors + ; Request uninstallation of an old Electron Cash installation ReadRegStr $R0 ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" UninstallString ReadRegStr $R1 ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" DisplayName ${If} $R0 != "" ${AndIf} ${Cmd} ${|} MessageBox MB_YESNO|MB_ICONEXCLAMATION "$R1 has already been installed. $\nDo you want to remove the previous version before installing $(^Name)?" /SD IDNO IDYES ${|} - ExecWait $R0 + GetFullPathName $R1 "$R0\.." + ExecWait '$R0 _?=$R1' + IfErrors 0 +2 + Abort ${EndIf} ; Check for administrator rights @@ -212,5 +248,9 @@ Section "Uninstall" SectionEnd Function UN.onInit + ; Ensure the process is not running in the uninstallation directory + Push $INSTDIR + Call un.EnsureNotRunning + !insertmacro UNINSTALL.LOG_BEGIN_UNINSTALL FunctionEnd diff --git a/contrib/deterministic-build/requirements-android.txt b/contrib/deterministic-build/requirements-android.txt index 94e69fda0572..2b020c6e2141 100644 --- a/contrib/deterministic-build/requirements-android.txt +++ b/contrib/deterministic-build/requirements-android.txt @@ -25,8 +25,6 @@ cryptography==2.8 \ --hash=sha256:d1ff320140efbb25ae14881125dd3de9cbb644f73c5484c3344ec64fdeef47de \ --hash=sha256:ebfc7a6288cbb033b543e442ba3cbc3f50b0deecaa97363f6c7f4cab41b66242 \ --hash=sha256:b6759a93e961e5e21e4ac08cf90d3e7a3523f10fbd15d02cf1a47e32020761df -pycparser==2.19 \ - --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 pycryptodomex==3.9.4 \ --hash=sha256:ba1ef18289edab0e922459aec301d2b0266d852d013e15c22e3ae789d327b0bd \ --hash=sha256:b13eb31badc12f65078cb561d3ca2d0b289f22dc189a2ce5a3146c8e132092b5 \ diff --git a/contrib/deterministic-build/requirements-build-wine.txt b/contrib/deterministic-build/requirements-build-wine.txt index d990f0be1662..97ce612dec69 100644 --- a/contrib/deterministic-build/requirements-build-wine.txt +++ b/contrib/deterministic-build/requirements-build-wine.txt @@ -5,9 +5,6 @@ future==0.18.2 \ --hash=sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d pefile==2019.4.18 \ --hash=sha256:a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645 -pip==21.0.1 \ - --hash=sha256:37fd50e056e2aed635dec96594606f0286640489b0db0ce7607f7e51890372d5 \ - --hash=sha256:99bbde183ec5ec037318e774b0d8ae0a64352fe53b2c7fd630be1d07e94f41e5 pycparser==2.20 \ --hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \ --hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 diff --git a/contrib/deterministic-build/requirements-pip.txt b/contrib/deterministic-build/requirements-pip.txt new file mode 100644 index 000000000000..764c8a374419 --- /dev/null +++ b/contrib/deterministic-build/requirements-pip.txt @@ -0,0 +1,3 @@ +pip==21.3.1 \ + --hash=sha256:deaf32dcd9ab821e359cd8330786bcd077604b5c5730c0b096eda46f95c24a2d \ + --hash=sha256:fd11ba3d0fdb4c07fbc5ecbba0b1b719809420f25038f8ee3cd913d3faa3033a \ No newline at end of file diff --git a/contrib/deterministic-build/requirements.txt b/contrib/deterministic-build/requirements.txt index 728791246403..79e207516788 100644 --- a/contrib/deterministic-build/requirements.txt +++ b/contrib/deterministic-build/requirements.txt @@ -60,9 +60,9 @@ jsonrpclib-pelix==0.4.2 \ pathvalidate==2.3.2 \ --hash=sha256:378c8b319838a255c00ab37f664686b75f0aabea4444d6c5a34effbec6738285 \ --hash=sha256:cae8ad5cd9223c5c1f4bc4e2ef0cd4c5e89acd2d698fdb7610ee108b9be654d2 -pip==21.0.1 \ - --hash=sha256:37fd50e056e2aed635dec96594606f0286640489b0db0ce7607f7e51890372d5 \ - --hash=sha256:99bbde183ec5ec037318e774b0d8ae0a64352fe53b2c7fd630be1d07e94f41e5 +pip==21.3.1 \ + --hash=sha256:deaf32dcd9ab821e359cd8330786bcd077604b5c5730c0b096eda46f95c24a2d \ + --hash=sha256:fd11ba3d0fdb4c07fbc5ecbba0b1b719809420f25038f8ee3cd913d3faa3033a protobuf==3.14.0 \ --hash=sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c \ --hash=sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836 \ @@ -84,6 +84,9 @@ protobuf==3.14.0 \ --hash=sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1 pyaes==1.6.1 \ --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f +pycparser==2.20 \ + --hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 \ + --hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 PySocks==1.7.1 \ --hash=sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299 \ --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ diff --git a/contrib/make_locale b/contrib/make_locale index f6752c2b29bd..421a25e3336a 100755 --- a/contrib/make_locale +++ b/contrib/make_locale @@ -1,16 +1,22 @@ #!/usr/bin/env python3 + +import glob +import io +import itertools import os from os.path import isdir, join +from pathlib import Path +import requests +import shlex +from subprocess import check_call import sys -import io import zipfile -import requests -import glob -import itertools -from pathlib import Path assert len(sys.argv) < 3 +def run(cmd): + check_call(shlex.split(cmd)) + original_dir = os.getcwd() os.chdir(os.path.dirname(os.path.realpath(__file__))) os.chdir('..') @@ -45,7 +51,7 @@ if not os.path.exists('electroncash/locale'): os.mkdir('electroncash/locale') cmd = 'xgettext -s --from-code UTF-8 --language Python --no-wrap -f app.fil --keyword=pgettext:1c,2 --keyword=npgettext:1c,2,3 -c --output=electroncash/locale/messages.pot' print('Generate template') -os.system(cmd) +run(cmd) os.chdir('electroncash') @@ -102,4 +108,4 @@ for lang in os.listdir('locale'): os.mkdir(msg_dir) cmd = 'msgfmt --output-file="{0}/electron-cash.mo" "{0}/electron-cash.po"'.format(msg_dir) print('Installing', lang) - os.system(cmd) + run(cmd) diff --git a/contrib/osx/README.md b/contrib/osx/README.md index 889633ce20a4..771c35389dde 100644 --- a/contrib/osx/README.md +++ b/contrib/osx/README.md @@ -4,8 +4,8 @@ Building Mac OS binaries ✗ _This script does not produce reproducible output (yet!)._ This guide explains how to build Electron Cash binaries for macOS systems. -We build our binaries on El Capitan (10.11.6) as building it on High Sierra -makes the binaries incompatible with older versions. +We build our binaries on Mojave (10.14.x) as building it on newer would +produce binaries that are incompatible with older Macs. This assumes that the Xcode Command Line tools (and thus git) are already installed. You can install older (and newer!) versions of Xcode from Apple provided you have a devloper account [from the Apple developer downloads site](https://developer.apple.com/download/more/). @@ -38,4 +38,9 @@ Or, if you wish to sign the app when building, provide an Apple developer identi ## 4. Done -You should see Electron-Cash.app and Electron-Cash-x.y.z.dmg in ../dist/. If you provided an identity for signing, these files can even be distributed to other Macs and they will run there without warnings from GateKeeper. +You should see Electron-Cash.app and Electron-Cash-x.y.z.dmg in ../dist/. If you provided an identity for signing, these +files can even be distributed to other Macs and they will run there without warnings from GateKeeper*. + +`*` Note that on newer Macs, the app won't run if downloaded unless it is **notarized by Apple**. That process is +somewhat involved and is not covered by this document. Search online for how apps for macOS can be notarized. + diff --git a/contrib/osx/make_osx b/contrib/osx/make_osx index 997ff7e6b080..cd1dc215515e 100755 --- a/contrib/osx/make_osx +++ b/contrib/osx/make_osx @@ -129,16 +129,18 @@ PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install -s $PYTHON_VERSION && \ # We use a hashed requirements file for even the build tools to prevent # dependency attacks even in the build process -info "Installing dmgbuild, requests, and other build tools we need..." +info "Installing pip, dmgbuild, requests, and other build tools we need..." # Ensure we have wheel because otherwise we get warnings about not having it (even though below installs it again) python3 -m pip install --user --upgrade wheel || fail "Failed to install wheel" +python3 -m pip install -I --user -r contrib/deterministic-build/requirements-pip.txt \ + || fail "Could not install pip" python3 -m pip install -I --user -r contrib/osx/requirements-osx-build.txt \ - || fail "Could not install osx-requirements" + || fail "Could not install osx build requirements" info "Installing PyInstaller" -# Just use regular, latest PyInstaller 4.2+ from pypi -python3 -m pip install pyinstaller --user --upgrade && pyenv rehash || fail "Could not install PyInstaller" +# Just use regular, latest PyInstaller 4.2+ from pypi (note that 4.4 seems to crash always!) +python3 -m pip install 'pyinstaller<4.4' --user --upgrade && pyenv rehash || fail "Could not install PyInstaller" info "Using these versions for building $PACKAGE:" # NB: PACKAGE var comes from ../base.sh sw_vers @@ -226,8 +228,13 @@ plutil -insert 'NSCameraUsageDescription' \ -- dist/$PACKAGE.app/Contents/Info.plist \ || fail "Could not add keys to Info.plist. Make sure the program 'plutil' exists and is installed." +plutil -insert 'LSMinimumSystemVersion' \ + -string '10.14.0' \ + -- dist/$PACKAGE.app/Contents/Info.plist \ + || fail "Could not add keys to Info.plist. Make sure the program 'plutil' exists and is installed." -FORCE_MOJAVE_DARK=0 # Set to 1 to try and add the Info.plist key to force mojave dark mode supprt. As of PyQt 5.14.1, it doesn't work. +FORCE_MOJAVE_DARK=1 # Set to 1 to try and add the Info.plist key to force mojave dark mode support. + # On PyQt 5.14.1, it doesn't work, but on 5.15.2 it does. if ((DARWIN_VER >= 18 && FORCE_MOJAVE_DARK)); then # Add a key to Info.plist key to support Mojave dark mode info "Adding Mojave dark mode support to Info.plist" diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index 2af327cb35d2..2766d8a90e9a 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -33,9 +33,7 @@ datas = [ (home+'electroncash/servers_testnet.json', PYPKG), (home+'electroncash/servers_testnet4.json', PYPKG), (home+'electroncash/servers_scalenet.json', PYPKG), - (home+'electroncash/servers_taxcoin.json', PYPKG), (home+'electroncash/wordlist/english.txt', PYPKG + '/wordlist'), - (home+'electroncash_gui/qt/data/ard_mone.mp3', PYPKG + '_gui' + '/data'), (home+'electroncash/locale', PYPKG + '/locale'), (home+'electroncash_plugins', PYPKG + '_plugins'), ] diff --git a/contrib/osx/requirements-osx-build.txt b/contrib/osx/requirements-osx-build.txt index 7b0aa31bbea8..fb49a5387873 100644 --- a/contrib/osx/requirements-osx-build.txt +++ b/contrib/osx/requirements-osx-build.txt @@ -25,9 +25,9 @@ macholib==1.14 \ --hash=sha256:0c436bc847e7b1d9bda0560351bf76d7caf930fb585a828d13608839ef42c432 pefile==2019.4.18 \ --hash=sha256:a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645 -pip==21.0.1 \ - --hash=sha256:37fd50e056e2aed635dec96594606f0286640489b0db0ce7607f7e51890372d5 \ - --hash=sha256:99bbde183ec5ec037318e774b0d8ae0a64352fe53b2c7fd630be1d07e94f41e5 +pip==21.3.1 \ + --hash=sha256:deaf32dcd9ab821e359cd8330786bcd077604b5c5730c0b096eda46f95c24a2d \ + --hash=sha256:fd11ba3d0fdb4c07fbc5ecbba0b1b719809420f25038f8ee3cd913d3faa3033a requests==2.25.1 \ --hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e \ --hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804 diff --git a/contrib/sign_packages b/contrib/sign_packages index d11ef5fc31f2..b7c3a37c59b4 100755 --- a/contrib/sign_packages +++ b/contrib/sign_packages @@ -10,9 +10,6 @@ if __name__ == '__main__': for f in os.listdir('.'): if f.endswith('asc'): continue - os.system( "gpg --sign --armor --detach --passphrase \"%s\" %s"%(password, f) ) + os.system("gpg --sign --armor --detach --passphrase \"%s\" %s"%(password, f)) os.chdir("..") - - - diff --git a/contrib/update_checker/releases.json b/contrib/update_checker/releases.json index 4ef4659cc5e8..166ca4d1d554 100644 --- a/contrib/update_checker/releases.json +++ b/contrib/update_checker/releases.json @@ -1,6 +1,6 @@ { - "4.2.3": { - "bitcoincash:qphax4cg8sxuc0qnzk6sx25939ma7y877uz04s2z82": "G4Ows5ROkK1hkChmyoj19sj+subfPQ8WeQ/7LNBJuMP+O5vPwfV007cIU9/qXsO3i3QWvp9qHSWQaJgyr1FGhuE=" + "4.2.6": { + "bitcoincash:qphax4cg8sxuc0qnzk6sx25939ma7y877uz04s2z82": "G9/B8eLSiNcsArI1RkuAJnnKBfuzQptrJZ90Dd9vkADXWoQ7CgqP/gPfMiTbPsjfOeGuHdGNNrxXWQ1xqf9FLkg=" }, "3.3.4CS": { "bitcoincash:qphax4cg8sxuc0qnzk6sx25939ma7y877uz04s2z82": "HNkxAJzvWGP5/YPIJrZRQFK5btM1nd/NKCwWAlejc5oEJ6VNbzo6KfqOoIBpPTauK21Tp/qXY7SMmlO+6ssMPOA=" diff --git a/electron-cash b/electron-cash index 28e687a56674..3111177942f9 100755 --- a/electron-cash +++ b/electron-cash @@ -468,10 +468,9 @@ def process_config_options(args): have_testnet = config_options.get("testnet", False) have_testnet4 = config_options.get("testnet4", False) have_scalenet = config_options.get("scalenet", False) - have_taxcoin = config_options.get("taxcoin", False) - if have_testnet + have_testnet4 + have_scalenet + have_taxcoin > 1: + if have_testnet + have_testnet4 + have_scalenet > 1: sys.exit( - "Invalid combination of --testnet, --testnet4, --scalenet, and/or --taxcoin" + "Invalid combination of --testnet, --testnet4, and/or --scalenet" ) elif have_testnet: networks.set_testnet() @@ -479,8 +478,6 @@ def process_config_options(args): networks.set_testnet4() elif have_scalenet: networks.set_scalenet() - elif have_taxcoin: - networks.set_taxcoin() # check uri uri = config_options.get("url") diff --git a/electroncash/address.py b/electroncash/address.py index 336d3f4ea9d1..7ddd174b3079 100644 --- a/electroncash/address.py +++ b/electroncash/address.py @@ -23,13 +23,13 @@ # Many of the functions in this file are copied from ElectrumX -from collections import namedtuple import hashlib import struct +from collections import namedtuple +from typing import Union from . import cashaddr, networks -from enum import IntEnum -from .bitcoin import EC_KEY, is_minikey, minikey_to_private_key, SCRIPT_TYPES +from .bitcoin import EC_KEY, is_minikey, minikey_to_private_key, SCRIPT_TYPES, OpCodes, push_script_bytes from .util import cachedproperty, inv_dict _sha256 = hashlib.sha256 @@ -44,150 +44,6 @@ class ScriptError(Exception): '''Exception used for Script errors.''' -# Derived from Bitcoin-ABC script.h -class OpCodes(IntEnum): - # push value - OP_0 = 0x00 - OP_FALSE = OP_0 - OP_PUSHDATA1 = 0x4c - OP_PUSHDATA2 = 0x4d - OP_PUSHDATA4 = 0x4e - OP_1NEGATE = 0x4f - OP_RESERVED = 0x50 - OP_1 = 0x51 - OP_TRUE = OP_1 - OP_2 = 0x52 - OP_3 = 0x53 - OP_4 = 0x54 - OP_5 = 0x55 - OP_6 = 0x56 - OP_7 = 0x57 - OP_8 = 0x58 - OP_9 = 0x59 - OP_10 = 0x5a - OP_11 = 0x5b - OP_12 = 0x5c - OP_13 = 0x5d - OP_14 = 0x5e - OP_15 = 0x5f - OP_16 = 0x60 - - # control - OP_NOP = 0x61 - OP_VER = 0x62 - OP_IF = 0x63 - OP_NOTIF = 0x64 - OP_VERIF = 0x65 - OP_VERNOTIF = 0x66 - OP_ELSE = 0x67 - OP_ENDIF = 0x68 - OP_VERIFY = 0x69 - OP_RETURN = 0x6a - - # stack ops - OP_TOALTSTACK = 0x6b - OP_FROMALTSTACK = 0x6c - OP_2DROP = 0x6d - OP_2DUP = 0x6e - OP_3DUP = 0x6f - OP_2OVER = 0x70 - OP_2ROT = 0x71 - OP_2SWAP = 0x72 - OP_IFDUP = 0x73 - OP_DEPTH = 0x74 - OP_DROP = 0x75 - OP_DUP = 0x76 - OP_NIP = 0x77 - OP_OVER = 0x78 - OP_PICK = 0x79 - OP_ROLL = 0x7a - OP_ROT = 0x7b - OP_SWAP = 0x7c - OP_TUCK = 0x7d - - # splice ops - OP_CAT = 0x7e - OP_SPLIT = 0x7f # after monolith upgrade (May 2018) - OP_NUM2BIN = 0x80 # after monolith upgrade (May 2018) - OP_BIN2NUM = 0x81 # after monolith upgrade (May 2018) - OP_SIZE = 0x82 - - # bit logic - OP_INVERT = 0x83 - OP_AND = 0x84 - OP_OR = 0x85 - OP_XOR = 0x86 - OP_EQUAL = 0x87 - OP_EQUALVERIFY = 0x88 - OP_RESERVED1 = 0x89 - OP_RESERVED2 = 0x8a - - # numeric - OP_1ADD = 0x8b - OP_1SUB = 0x8c - OP_2MUL = 0x8d - OP_2DIV = 0x8e - OP_NEGATE = 0x8f - OP_ABS = 0x90 - OP_NOT = 0x91 - OP_0NOTEQUAL = 0x92 - - OP_ADD = 0x93 - OP_SUB = 0x94 - OP_MUL = 0x95 - OP_DIV = 0x96 - OP_MOD = 0x97 - OP_LSHIFT = 0x98 - OP_RSHIFT = 0x99 - - OP_BOOLAND = 0x9a - OP_BOOLOR = 0x9b - OP_NUMEQUAL = 0x9c - OP_NUMEQUALVERIFY = 0x9d - OP_NUMNOTEQUAL = 0x9e - OP_LESSTHAN = 0x9f - OP_GREATERTHAN = 0xa0 - OP_LESSTHANOREQUAL = 0xa1 - OP_GREATERTHANOREQUAL = 0xa2 - OP_MIN = 0xa3 - OP_MAX = 0xa4 - - OP_WITHIN = 0xa5 - - # crypto - OP_RIPEMD160 = 0xa6 - OP_SHA1 = 0xa7 - OP_SHA256 = 0xa8 - OP_HASH160 = 0xa9 - OP_HASH256 = 0xaa - OP_CODESEPARATOR = 0xab - OP_CHECKSIG = 0xac - OP_CHECKSIGVERIFY = 0xad - OP_CHECKMULTISIG = 0xae - OP_CHECKMULTISIGVERIFY = 0xaf - - # expansion - OP_NOP1 = 0xb0 - OP_CHECKLOCKTIMEVERIFY = 0xb1 - OP_NOP2 = OP_CHECKLOCKTIMEVERIFY - OP_CHECKSEQUENCEVERIFY = 0xb2 - OP_NOP3 = OP_CHECKSEQUENCEVERIFY - OP_NOP4 = 0xb3 - OP_NOP5 = 0xb4 - OP_NOP6 = 0xb5 - OP_NOP7 = 0xb6 - OP_NOP8 = 0xb7 - OP_NOP9 = 0xb8 - OP_NOP10 = 0xb9 - - # More crypto - OP_CHECKDATASIG = 0xba - OP_CHECKDATASIGVERIFY = 0xbb - - # additional byte string operations - OP_REVERSEBYTES = 0xbc - - P2PKH_prefix = bytes([OpCodes.OP_DUP, OpCodes.OP_HASH160, 20]) P2PKH_suffix = bytes([OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG]) @@ -422,7 +278,7 @@ def lookup(x): friendlystring = data.hex() parts.append(lookup(op) + " " + friendlystring) - else: # isinstance(op, int): + else: # isinstance(op, int): parts.append(lookup(op)) return ', '.join(parts) @@ -764,26 +620,17 @@ def multisig_script(cls, m, pubkeys): PublicKey.validate(pubkey) # Can be compressed or not # See https://bitcoin.org/en/developer-guide # 2 of 3 is: OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG - return (bytes([OpCodes.OP_1 + m - 1]) + return (cls.push_data(bytes([m])) + b''.join(cls.push_data(pubkey) for pubkey in pubkeys) - + bytes([OpCodes.OP_1 + n - 1, OpCodes.OP_CHECKMULTISIG])) + + cls.push_data(bytes([n])) + bytes([OpCodes.OP_CHECKMULTISIG])) @classmethod - def push_data(cls, data): - '''Returns the OpCodes to push the data on the stack.''' - assert isinstance(data, (bytes, bytearray)) - - n = len(data) - if n < OpCodes.OP_PUSHDATA1: - return bytes([n]) + data - if n < 256: - return bytes([OpCodes.OP_PUSHDATA1, n]) + data - if n < 65536: - return bytes([OpCodes.OP_PUSHDATA2]) + struct.pack(' bytes: + """Returns the OpCodes to push the data on the stack, plus the payload.""" + return push_script_bytes(data, minimal=minimal) @classmethod - def get_ops(cls, script): + def get_ops(cls, script, *, synthesize_minimal_data=True): ops = [] # The unpacks or script[n] below throw on truncated scripts @@ -805,7 +652,7 @@ def get_ops(cls, script): # Two-byte length, then data dlen, = struct.unpack(' bytes: + assert isinstance(data_len, int) and data_len >= 0 + if data_len < OpCodes.OP_PUSHDATA1: + return data_len.to_bytes(byteorder='little', length=1) + elif data_len <= 0xff: + return bytes([OpCodes.OP_PUSHDATA1]) + data_len.to_bytes(byteorder='little', length=1) + elif data_len <= 0xffff: + return bytes([OpCodes.OP_PUSHDATA2]) + data_len.to_bytes(byteorder='little', length=2) else: - return '4e' + int_to_hex(i,4) + return bytes([OpCodes.OP_PUSHDATA4]) + data_len.to_bytes(byteorder='little', length=4) + + +def op_push(i: int) -> str: + """Hex version of above""" + return op_push_bytes(i).hex() + + +def push_script_bytes(data: Union[bytearray, bytes], *, minimal=True) -> bytes: + """Returns pushed data to the script, automatically respecting BIP62 "minimal encoding" rules. + If `minimal` is False, will not use BIP62 and will just push using OP_PUSHDATA*, etc (this + non-BIP62 way of pushing is the convention in OP_RETURN scripts such as CashAccounts usually). + Input data is bytes, returns bytes.""" + assert isinstance(data, (bytes, bytearray)) + data_len = len(data) + + if minimal: + # BIP62 has bizarre rules for minimal pushes of length 0 or 1 + # See: https://en.bitcoin.it/wiki/BIP_0062#Push_operators + # And also: https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node/-/blob/master/src/script/script.cpp#L300 + if data_len == 0 or data_len == 1 and data[0] == 0: + return bytes([OpCodes.OP_0]) + elif data_len == 1 and 1 <= data[0] <= 16: + return bytes([OpCodes.OP_1 + (data[0] - 1)]) + elif data_len == 1 and data[0] == 0x81: + return bytes([OpCodes.OP_1NEGATE]) + + return op_push_bytes(data_len) + data + + +def push_script(data: str, *, minimal=True) -> str: + """Returns pushed data to the script, automatically respecting BIP62 "minimal encoding" rules. + Input data is hex, returns hex.""" + return push_script_bytes(bytes.fromhex(data), minimal=minimal).hex() -def push_script(x): - return op_push(len(x)//2) + x def sha256(x): x = to_bytes(x, 'utf8') @@ -443,7 +636,7 @@ def deserialize_privkey(key, *, net=None): 'but last byte is 0x{:02x} != 0x01'.format(vch[33])) return txin_type, vch[1:33], compressed else: - raise ValueError("cannot deserialize", key) + raise ValueError("Not a valid private key: '%s'" % key) def regenerate_key(pk): assert len(pk) == 32 diff --git a/electroncash/blockchain.py b/electroncash/blockchain.py index 660ff0f01fa1..84b6773d22a6 100644 --- a/electroncash/blockchain.py +++ b/electroncash/blockchain.py @@ -33,56 +33,69 @@ from .bitcoin import * + class VerifyError(Exception): - '''Exception used for blockchain verification errors.''' + """Exception used for blockchain verification errors.""" + CHUNK_FORKS = -3 CHUNK_BAD = -2 CHUNK_LACKED_PROOF = -1 CHUNK_ACCEPTED = 0 +HEADER_SIZE = 80 # bytes +MAX_BITS = 0x1d00ffff +# see https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node/-/blob/v24.0.0/src/chainparams.cpp#L98 +# Note: If we decide to support REGTEST this will need to come from regtest's networks.py params! +MAX_TARGET = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff # compact: 0x1d00ffff +# indicates no header in data file +NULL_HEADER = bytes([0]) * HEADER_SIZE +NULL_HASH_BYTES = bytes([0]) * 32 +NULL_HASH_HEX = NULL_HASH_BYTES.hex() + + def bits_to_work(bits): return (1 << 256) // (bits_to_target(bits) + 1) -def bits_to_target(bits): - if bits == 0: - return 0 - size = bits >> 24 - assert size <= 0x1d - word = bits & 0x00ffffff - assert 0x8000 <= word <= 0x7fffff - - if size <= 3: - return word >> (8 * (3 - size)) - else: - return word << (8 * (size - 3)) - -def target_to_bits(target): - if target == 0: - return 0 - target = min(target, MAX_TARGET) - size = (target.bit_length() + 7) // 8 - mask64 = 0xffffffffffffffff - if size <= 3: - compact = (target & mask64) << (8 * (3 - size)) +def bits_to_target(bits: int) -> int: + # arith_uint256::SetCompact in Bitcoin Cash Node + # see https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node/-/blob/v24.0.0/src/arith_uint256.cpp#L208 + if not (0 <= bits < (1 << 32)): + raise Exception(f"bits should be uint32. got {bits!r}") + bitsN = (bits >> 24) & 0xff + bitsBase = bits & 0x7fffff + if bitsN <= 3: + target = bitsBase >> (8 * (3 - bitsN)) else: - compact = (target >> (8 * (size - 3))) & mask64 + target = bitsBase << (8 * (bitsN - 3)) + if target != 0 and bits & 0x800000 != 0: + # Bit number 24 (0x800000) represents the sign of N + raise Exception("target cannot be negative") + if (target != 0 and + (bitsN > 34 or + (bitsN > 33 and bitsBase > 0xff) or + (bitsN > 32 and bitsBase > 0xffff))): + raise Exception("target has overflown") + return target + + +def target_to_bits(target: int) -> int: + # arith_uint256::GetCompact in Bitcoin Cash Node + # see https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node/-/blob/v24.0.0/src/arith_uint256.cpp#L230 + c = target.to_bytes(length=32, byteorder='big') + bitsN = len(c) + while bitsN > 0 and c[0] == 0: + c = c[1:] + bitsN -= 1 + if len(c) < 3: + c += b'\x00' + bitsBase = int.from_bytes(c[:3], byteorder='big') + if bitsBase >= 0x800000: + bitsN += 1 + bitsBase >>= 8 + return bitsN << 24 | bitsBase - if compact & 0x00800000: - compact >>= 8 - size += 1 - assert compact == (compact & 0x007fffff) - assert size < 256 - return compact | size << 24 - -HEADER_SIZE = 80 # bytes -MAX_BITS = 0x1d00ffff -MAX_TARGET = bits_to_target(MAX_BITS) -# indicates no header in data file -NULL_HEADER = bytes([0]) * HEADER_SIZE -NULL_HASH_BYTES = bytes([0]) * 32 -NULL_HASH_HEX = NULL_HASH_BYTES.hex() def serialize_header(res): s = int_to_hex(res.get('version'), 4) \ diff --git a/electroncash/cashacct.py b/electroncash/cashacct.py index 7432b9bba918..9d829efc0219 100644 --- a/electroncash/cashacct.py +++ b/electroncash/cashacct.py @@ -383,7 +383,7 @@ def create_registration(cls, name, address): class MyBCDataStream(BCDataStream): def push_data(self, data): self.input = self.input or bytearray() - self.input += Script.push_data(data) + self.input += Script.push_data(data, minimal=False) bcd = MyBCDataStream() bcd.write(cls._protocol_prefix) # OP_RETURN -> 0x6a + 0x4 (pushdata 4 bytes) + 0x01010101 (protocol code) bcd.push_data(name.encode('ascii')) diff --git a/electroncash/coinchooser.py b/electroncash/coinchooser.py index 798c11a7858c..51a05c1ccf3c 100644 --- a/electroncash/coinchooser.py +++ b/electroncash/coinchooser.py @@ -221,7 +221,7 @@ def bucket_candidates(self, buckets, sufficient_funds): # Add all singletons for n, bucket in enumerate(buckets): if sufficient_funds([bucket]): - candidates.add((n, )) + candidates.add((n,)) # And now some random ones attempts = min(100, (len(buckets) - 1) * 10 + 1) diff --git a/electroncash/commands.py b/electroncash/commands.py index 99c2202634ae..e8d6d1c5146d 100644 --- a/electroncash/commands.py +++ b/electroncash/commands.py @@ -44,7 +44,8 @@ from .wallet import create_new_wallet, restore_wallet_from_text from .transaction import Transaction, multisig_script, OPReturn from .util import bfh, bh2u, format_satoshis, json_decode, print_error, to_bytes -from .paymentrequest import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .paymentrequest import PR_PAID, PR_UNCONFIRMED, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .simple_config import SimpleConfig known_commands = {} @@ -509,8 +510,10 @@ def verifymessage(self, address, signature, message): message = util.to_bytes(message) return bitcoin.verify_message(address, sig, message) - def _mktx(self, outputs, fee=None, change_addr=None, domain=None, nocheck=False, + def _mktx(self, outputs, fee=None, feerate=None, change_addr=None, domain=None, nocheck=False, unsigned=False, password=None, locktime=None, op_return=None, op_return_raw=None, addtransaction=False): + if fee is not None and feerate is not None: + raise ValueError("Cannot specify both 'fee' and 'feerate' at the same time!") if op_return and op_return_raw: raise ValueError('Both op_return and op_return_raw cannot be specified together!') self.nocheck = nocheck @@ -534,7 +537,12 @@ def _mktx(self, outputs, fee=None, change_addr=None, domain=None, nocheck=False, final_outputs.append((TYPE_ADDRESS, address, amount)) coins = self.wallet.get_spendable_coins(domain, self.config) - tx = self.wallet.make_unsigned_transaction(coins, final_outputs, self.config, fee, change_addr) + if feerate is not None: + fee_per_kb = 1000 * PyDecimal(feerate) + fee_estimator = lambda size: SimpleConfig.estimate_fee_for_feerate(fee_per_kb, size) + else: + fee_estimator = fee + tx = self.wallet.make_unsigned_transaction(coins, final_outputs, self.config, fee_estimator, change_addr) if locktime != None: tx.locktime = locktime if not unsigned: @@ -547,20 +555,20 @@ def _mktx(self, outputs, fee=None, change_addr=None, domain=None, nocheck=False, return tx @command('wp') - def payto(self, destination, amount, fee=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, + def payto(self, destination, amount, fee=None, feerate=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, op_return=None, op_return_raw=None, addtransaction=False): """Create a transaction. """ tx_fee = satoshis(fee) domain = from_addr.split(',') if from_addr else None - tx = self._mktx([(destination, amount)], tx_fee, change_addr, domain, nocheck, unsigned, password, locktime, op_return, op_return_raw, addtransaction=addtransaction) + tx = self._mktx([(destination, amount)], tx_fee, feerate, change_addr, domain, nocheck, unsigned, password, locktime, op_return, op_return_raw, addtransaction=addtransaction) return tx.as_dict() @command('wp') - def paytomany(self, outputs, fee=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, addtransaction=False): + def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, addtransaction=False): """Create a multi-output transaction. """ tx_fee = satoshis(fee) domain = from_addr.split(',') if from_addr else None - tx = self._mktx(outputs, tx_fee, change_addr, domain, nocheck, unsigned, password, locktime, addtransaction=addtransaction) + tx = self._mktx(outputs, tx_fee, feerate, change_addr, domain, nocheck, unsigned, password, locktime, addtransaction=addtransaction) return tx.as_dict() @command('w') @@ -702,6 +710,7 @@ def _format_request(self, out): PR_UNPAID: 'Pending', PR_PAID: 'Paid', PR_EXPIRED: 'Expired', + PR_UNCONFIRMED: 'Unconfirmed' } out['address'] = out.get('address').to_ui_string() out['amount (BCH)'] = format_satoshis(out.get('amount')) @@ -857,7 +866,8 @@ def help(self): 'entropy': (None, "Custom entropy"), 'expiration': (None, "Time in seconds"), 'expired': (None, "Show only expired requests."), - 'fee': ("-f", "Transaction fee (in BCH)"), + 'fee': ("-f", "Transaction fee (absolute, in BCH)"), + 'feerate': (None, "Transaction fee rate (in sat/byte)"), 'force': (None, "Create new address beyond gap limit, if no more addresses are available."), 'from_addr': ("-F", "Source address (must be a wallet address; use sweep to spend from non-wallet address)."), 'frozen': (None, "Show only frozen addresses"), @@ -989,10 +999,10 @@ def add_global_options(parser): group.add_argument("-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electron_cash_data' directory") group.add_argument("-w", "--wallet", dest="wallet_path", help="wallet path") group.add_argument("-wp", "--walletpassword", dest="wallet_password", default=None, help="Supply wallet password") + group.add_argument("--forgetconfig", action="store_true", dest="forget_config", default=False, help="Forget config on exit") group.add_argument("--testnet", action="store_true", dest="testnet", default=False, help="Use Testnet") group.add_argument("--testnet4", action="store_true", dest="testnet4", default=False, help="Use Testnet4") group.add_argument("--scalenet", action="store_true", dest="scalenet", default=False, help="Use Scalenet") - group.add_argument("--taxcoin", action="store_true", dest="taxcoin", default=False, help="Use TaxCoin (ABC)") def get_parser(): # create main parser diff --git a/electroncash/contacts.py b/electroncash/contacts.py index 5dfec72cacd9..b48593993eb4 100644 --- a/electroncash/contacts.py +++ b/electroncash/contacts.py @@ -263,7 +263,7 @@ def resolve_openalias(cls, url): for record in records: string = record.strings[0].decode('utf-8') if string.startswith('oa1:' + prefix): - address = cls.find_regex(string, r'recipient_address=([A-Za-z0-9]+)') + address = cls.find_regex(string, r'recipient_address=([A-Za-z0-9:]+)') name = cls.find_regex(string, r'recipient_name=([^;]+)') if not name: name = address diff --git a/electroncash/currencies.json b/electroncash/currencies.json index 687fcd6b19b4..3f9980c0577b 100644 --- a/electroncash/currencies.json +++ b/electroncash/currencies.json @@ -565,5 +565,8 @@ "XAU", "XDR", "ZAR" + ], + "BitstampYadio": [ + "ARS" ] } diff --git a/electroncash/daemon.py b/electroncash/daemon.py index 9cb7ac75b0b0..fa0ef614a10f 100644 --- a/electroncash/daemon.py +++ b/electroncash/daemon.py @@ -138,10 +138,11 @@ def get_rpc_credentials(config): class Daemon(DaemonThread): - def __init__(self, config, fd, is_gui, plugins): + def __init__(self, config, fd, is_gui, plugins, *, listen_jsonrpc=True): DaemonThread.__init__(self) self.plugins = plugins self.config = config + self.listen_jsonrpc = listen_jsonrpc if config.get('offline'): self.network = None else: @@ -155,9 +156,11 @@ def __init__(self, config, fd, is_gui, plugins): # On the testnets we don't offer exchange rate/fiat display (is_supported() == False). self.network.add_jobs([self.fx]) self.gui = None + self.server = None self.wallets = {} - # Setup JSONRPC server - self.init_server(config, fd, is_gui) + if listen_jsonrpc: + # Setup JSONRPC server + self.init_server(config, fd, is_gui) def init_server(self, config, fd, is_gui): host = config.get('rpchost', '127.0.0.1') @@ -169,7 +172,6 @@ def init_server(self, config, fd, is_gui): rpc_user=rpc_user, rpc_password=rpc_password) except Exception as e: self.print_error('Warning: cannot initialize RPC server on host', host, e) - self.server = None os.close(fd) return os.write(fd, bytes(repr((server.socket.getsockname(), time.time())), 'utf8')) @@ -338,8 +340,9 @@ def run(self): self.on_stop() def stop(self): - self.print_error("stopping, removing lockfile") - remove_lockfile(get_lockfile(self.config)) + if self.listen_jsonrpc: + self.print_error("stopping, removing lockfile") + remove_lockfile(get_lockfile(self.config)) super().stop() diff --git a/electroncash/exchange_rate.py b/electroncash/exchange_rate.py index 998692bed582..c3743118fa63 100644 --- a/electroncash/exchange_rate.py +++ b/electroncash/exchange_rate.py @@ -261,6 +261,12 @@ def request_history(self, ccy): return dict([(dt.utcfromtimestamp(h[0]/1000).strftime('%Y-%m-%d'), h[1]) for h in history['prices']]) +class BitstampYadio(ExchangeBase): + def get_rates(self, ccy): + json_usd = self.get_json('www.bitstamp.net', '/api/v2/ticker/bchusd') + json_ars = self.get_json('api.yadio.io', '/exrates/ARS') + return {'ARS': PyDecimal(json_usd['last']) / PyDecimal(json_ars['ARS']['USD'])} + def dictinvert(d): inv = {} @@ -374,10 +380,10 @@ def run(self): @staticmethod def is_supported(): """Fiat currency is only supported on BCH MainNet, for all other chains it is not supported.""" - return not networks.net.TESTNET and networks.net is not networks.TaxCoinNet + return not networks.net.TESTNET def is_enabled(self): - return self.is_supported() and self.config.get('use_exchange_rate', DEFAULT_ENABLED) + return bool(self.is_supported() and self.config.get('use_exchange_rate', DEFAULT_ENABLED)) def set_enabled(self, b): return self.config.set_key('use_exchange_rate', bool(b)) diff --git a/electroncash/mnemonic.py b/electroncash/mnemonic.py index 077160b2b641..0800c2a93177 100644 --- a/electroncash/mnemonic.py +++ b/electroncash/mnemonic.py @@ -52,7 +52,7 @@ (0x2B740, 0x2B81F, 'CJK Unified Ideographs Extension D'), (0xF900, 0xFAFF, 'CJK Compatibility Ideographs'), (0x2F800, 0x2FA1D, 'CJK Compatibility Ideographs Supplement'), - (0x3190, 0x319F , 'Kanbun'), + (0x3190, 0x319F, 'Kanbun'), (0x2E80, 0x2EFF, 'CJK Radicals Supplement'), (0x2F00, 0x2FDF, 'CJK Radicals'), (0x31C0, 0x31EF, 'CJK Strokes'), diff --git a/electroncash/network.py b/electroncash/network.py index 062c43144a80..6cd228b958cc 100644 --- a/electroncash/network.py +++ b/electroncash/network.py @@ -418,7 +418,8 @@ def save_recent_servers(self): pass def get_server_height(self): - return self.interface.tip if self.interface else 0 + with self.interface_lock: + return self.interface.tip if self.interface else 0 def server_is_lagging(self): sh = self.get_server_height() @@ -1887,6 +1888,8 @@ def transmogrify_broadcast_response_for_gui(server_msg): elif r"absurdly-high-fee" in server_msg: return _("The transaction was rejected because it specifies an absurdly high fee.") elif r"non-mandatory-script-verify-flag" in server_msg or r"mandatory-script-verify-flag-failed" in server_msg or r"upgrade-conditional-script-failure" in server_msg: + if r"push larger than necessary" in server_msg: + return _("The transaction was rejected due to a non-minimal data push") return _("The transaction was rejected due to an error in script execution.") elif r"tx-size" in server_msg or r"bad-txns-oversize" in server_msg: return _("The transaction was rejected because it is too large (in bytes).") diff --git a/electroncash/networks.py b/electroncash/networks.py index 0729554bb5be..b206c219e1bb 100644 --- a/electroncash/networks.py +++ b/electroncash/networks.py @@ -73,8 +73,8 @@ class MainNet(AbstractNet): # network.synchronous_get(("blockchain.block.header", [height, height])) # # Consult the ElectrumX documentation for more details. - VERIFICATION_BLOCK_MERKLE_ROOT = "68077352cf309072547164625deb11e92bd379e759e87f3f9ac6e61d1532c536" - VERIFICATION_BLOCK_HEIGHT = 661942 + VERIFICATION_BLOCK_MERKLE_ROOT = "60b3f9f9e439fd5f6c95ae380b91a480359657afd9206a9274f66fd42845273b" + VERIFICATION_BLOCK_HEIGHT = 717171 asert_daa = ASERTDaa(is_testnet=False) # Note: We *must* specify the anchor if the checkpoint is after the anchor, due to the way # blockchain.py skips headers after the checkpoint. So all instances that have a checkpoint @@ -115,8 +115,8 @@ class TestNet(AbstractNet): BITCOIN_CASH_FORK_BLOCK_HEIGHT = 1155876 BITCOIN_CASH_FORK_BLOCK_HASH = "00000000000e38fef93ed9582a7df43815d5c2ba9fd37ef70c9a0ea4a285b8f5" - VERIFICATION_BLOCK_MERKLE_ROOT = "d97d670815829fddcf728fa2d29665de53e83609fd471b0716a49cde383fb888" - VERIFICATION_BLOCK_HEIGHT = 1421482 + VERIFICATION_BLOCK_MERKLE_ROOT = "7551842b70e20582390f5693ffce71df5509f5a3f6e32ac0f91123231dbcf97a" + VERIFICATION_BLOCK_HEIGHT = 1476226 asert_daa = ASERTDaa(is_testnet=True) asert_daa.anchor = Anchor(height=1421481, bits=486604799, prev_time=1605445400) @@ -146,13 +146,12 @@ class TestNet4(TestNet): # Nov 13. 2017 HF to CW144 DAA height (height of last block mined on old DAA) CW144_HEIGHT = 3000 - VERIFICATION_BLOCK_MERKLE_ROOT = "9ca8933d4aa7b85093e3ec317e40bdfeda3e2b793fcd7907b38580fa193d9c77" - VERIFICATION_BLOCK_HEIGHT = 16845 + VERIFICATION_BLOCK_MERKLE_ROOT = "e4cd956daecf2a1d2894954bb479f09e6d2d488e470ed59e1af6a329170597d6" + VERIFICATION_BLOCK_HEIGHT = 68611 asert_daa = ASERTDaa(is_testnet=True) # Redeclare to get instance for this subclass asert_daa.anchor = Anchor(height=16844, bits=486604799, prev_time=1605451779) - class ScaleNet(TestNet): GENESIS = "00000000e6453dc2dfe1ffa19023f86002eb11dbb8e87d0291a4599f0430be52" TITLE = 'Electron Cash Scalenet' @@ -177,58 +176,6 @@ class ScaleNet(TestNet): asert_daa.anchor = None # Intentionally not specified because it's after checkpoint; blockchain.py will calculate - -class TaxCoinNet(AbstractNet): - """ This is for supporting ABC tax coin. Use CLI arg --taxcoin to see this network. - Users using this network cannot see BCH and vice-versa, due to the checkpoint block. - If one wants to see both chains one can run 2 clients since they will use different data - directories. """ - TESTNET = False - WIF_PREFIX = 0x80 - ADDRTYPE_P2PKH = 0 - ADDRTYPE_P2PKH_BITPAY = 28 - ADDRTYPE_P2SH = 5 - ADDRTYPE_P2SH_BITPAY = 40 - CASHADDR_PREFIX = "bitcoincash" - HEADERS_URL = "http://bitcoincash.com/files/blockchain_headers" # Unused - GENESIS = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" - DEFAULT_PORTS = {'t': '50001', 's': '50002'} - DEFAULT_SERVERS = _read_json_dict('servers_taxcoin.json') # DO NOT MODIFY IN CLIENT CODE - TITLE = "Electron Tax - 'Ard Moné Edition" - BASE_UNITS = {'TAX': 8, 'mTAX': 5, 'sechets': 2} - DEFAULT_UNIT = "TAX" - - # Bitcoin Cash fork block specification - BITCOIN_CASH_FORK_BLOCK_HEIGHT = 478559 - BITCOIN_CASH_FORK_BLOCK_HASH = "000000000000000000651ef99cb9fcbe0dadde1d424bd9f15ff20136191a5eec" - - # Nov 13. 2017 HF to CW144 DAA height (height of last block mined on old DAA) - CW144_HEIGHT = 504031 - - # Note: this is not the Merkle root of the verification block itself , but a Merkle root of - # all blockchain headers up until and including this block. To get this value you need to - # connect to an ElectrumX server you trust and issue it a protocol command. This can be - # done in the console as follows: - # - # network.synchronous_get(("blockchain.block.header", [height, height])) - # - # Consult the ElectrumX documentation for more details. - VERIFICATION_BLOCK_MERKLE_ROOT = "d0d925862df595918416020caf5467b7ae67ae8f807daf60626c36755b62f9a2" - VERIFICATION_BLOCK_HEIGHT = 661648 # ABC fork block - asert_daa = ASERTDaa(is_testnet=False) - asert_daa.anchor = Anchor(height=661647, bits=402971390, prev_time=1605447844) - - # Version numbers for BIP32 extended keys - # standard: xprv, xpub - XPRV_HEADERS = { - 'standard': 0x0488ade4, - } - - XPUB_HEADERS = { - 'standard': 0x0488b21e, - } - - # All new code should access this to get the current network config. net = MainNet @@ -264,12 +211,6 @@ def set_scalenet(): _set_units() -def set_taxcoin(): - global net - net = TaxCoinNet - _set_units() - - # Compatibility def _instancer(cls): return cls() diff --git a/electroncash/old_mnemonic.py b/electroncash/old_mnemonic.py index e2102b45223b..ef793dd1ddff 100644 --- a/electroncash/old_mnemonic.py +++ b/electroncash/old_mnemonic.py @@ -1661,7 +1661,7 @@ # Note about US patent no 5892470: Here each word does not represent a given digit. # Instead, the digit represented by a word is variable, it depends on the previous word. -def mn_encode( message ): +def mn_encode(message): assert len(message) % 8 == 0 out = [] for i in range(len(message)//8): @@ -1670,11 +1670,11 @@ def mn_encode( message ): w1 = (x%n) w2 = ((x//n) + w1)%n w3 = ((x//n//n) + w2)%n - out += [ words[w1], words[w2], words[w3] ] + out += [words[w1], words[w2], words[w3]] return out -def mn_decode( wlist ): +def mn_decode(wlist): out = '' for i in range(len(wlist)//3): word1, word2, word3 = wlist[3*i:3*i+3] diff --git a/electroncash/paymentrequest.py b/electroncash/paymentrequest.py index 6ddc3c36d9a9..9f202228f8aa 100644 --- a/electroncash/paymentrequest.py +++ b/electroncash/paymentrequest.py @@ -60,12 +60,14 @@ def _(message): return message PR_EXPIRED = 1 PR_UNKNOWN = 2 # sent but not propagated PR_PAID = 3 # send and propagated +PR_UNCONFIRMED = 7 # paid and confirmations = 0 (7 used to match Electrum) pr_tooltips = { PR_UNPAID:_('Pending'), PR_UNKNOWN:_('Unknown'), PR_PAID:_('Paid'), - PR_EXPIRED:_('Expired') + PR_EXPIRED:_('Expired'), + PR_UNCONFIRMED: _('Unconfirmed') } del _ @@ -247,7 +249,7 @@ def verify_dnssec(self, pr, contacts): return False def has_expired(self): - return self.details.expires and self.details.expires < int(time.time()) + return bool(self.details.expires and self.details.expires < int(time.time())) def get_expiration_date(self): return self.details.expires diff --git a/electroncash/plot.py b/electroncash/plot.py index d0ffc5f0d4e3..59304c53a47e 100644 --- a/electroncash/plot.py +++ b/electroncash/plot.py @@ -33,7 +33,7 @@ def plot_history(wallet, history): f, axarr = plt.subplots(2, sharex=True) plt.subplots_adjust(bottom=0.2) - plt.xticks( rotation=25 ) + plt.xticks(rotation=25) ax = plt.gca() plt.ylabel('BCH') plt.xlabel('Month') diff --git a/electroncash/plugins.py b/electroncash/plugins.py index ef4d2e4bba7a..8bbc805a9088 100644 --- a/electroncash/plugins.py +++ b/electroncash/plugins.py @@ -41,7 +41,7 @@ from . import bitcoin from . import version from .i18n import _ -from .util import (print_error, print_stderr, make_dir, profiler, user_dir, +from .util import (print_error, print_stderr, make_dir, profiler, DaemonThread, PrintError, ThreadJob, UserCancelled) plugin_loaders = {} @@ -317,14 +317,9 @@ def is_external_plugin_available(self, name, w): return self.is_plugin_available(d, w) def get_external_plugin_dir(self): - # It's possible the plugins are being stored in a local directory - # and the rest of the data is being stored in the non-local directory. - local_user_dir = user_dir(prefer_local=True) - # Environment does not have a user directory (will be unit tests where there are no external plugins). - if local_user_dir is None: + if self.config.path is None: return None - make_dir(local_user_dir) - external_plugin_dir = os.path.join(local_user_dir, "external_plugins") + external_plugin_dir = os.path.join(self.config.path, "external_plugins") make_dir(external_plugin_dir) return external_plugin_dir diff --git a/electroncash/rsakey.py b/electroncash/rsakey.py index 205dfa8a83d3..2a2c25a84b91 100644 --- a/electroncash/rsakey.py +++ b/electroncash/rsakey.py @@ -125,7 +125,7 @@ def numBits(n): '8':4, '9':4, 'a':4, 'b':4, 'c':4, 'd':4, 'e':4, 'f':4, }[s[0]] - return int(math.floor(math.log(n, 2))+1) + def numBytes(n): if n==0: diff --git a/electroncash/servers.json b/electroncash/servers.json index a876613cc846..41181f76a23e 100644 --- a/electroncash/servers.json +++ b/electroncash/servers.json @@ -35,7 +35,7 @@ "s": "50002", "version": "1.4.1" }, - "bitcoincash.quangld.com": { + "bchisbitcoin.com": { "pruning": "-", "s": "50002", "t": "50001", @@ -94,7 +94,7 @@ "version": "1.4.1", "display": "electrum.imaginary.cash" }, - "kisternetg2pq7wx.onion": { + "kisternet5tgeekwidrj7r7yd3n2l5j7y72b74y6xu3q2b6xdjrte6id.onion": { "pruning": "-", "t": "50001", "version": "1.4.1" @@ -116,6 +116,11 @@ "t": "50001", "version": "1.4" }, + "electrs.electroncash.de": { + "pruning": "-", + "s": "40002", + "version": "1.4" + }, "jktsologn7uprtwn7gsgmwuddj6rxsqmwc2vaug7jwcwzm2bxqnfpwad.onion": { "pruning": "-", "s": "50002", @@ -155,5 +160,11 @@ "s": "50002", "t": "50001", "version": "1.4.4" + }, + "electrum.bitcoinverde.org": { + "pruning": "-", + "s": "50002", + "t": "50001", + "version": "1.4.4" } } diff --git a/electroncash/servers_scalenet.json b/electroncash/servers_scalenet.json index 3ee119074492..0c707c0e7ded 100644 --- a/electroncash/servers_scalenet.json +++ b/electroncash/servers_scalenet.json @@ -11,5 +11,14 @@ "s": "63002", "t": "63001", "display": "sbch.loping.net" + }, + "electroncash.de": { + "t": "55003", + "s": "55004" + }, + "jktsologn7uprtwn7gsgmwuddj6rxsqmwc2vaug7jwcwzm2bxqnfpwad.onion": { + "s": "55003", + "t": "55004", + "display": "electroncash.de" } } diff --git a/electroncash/servers_taxcoin.json b/electroncash/servers_taxcoin.json deleted file mode 100644 index c9ce93b08425..000000000000 --- a/electroncash/servers_taxcoin.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "taxchain.imaginary.cash": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.4.1" - }, - "electrum.bitcoinabc.org": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.4.1" - }, - "kingbch.com": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.4.1" - }, - "fulcrum-main.bchjs.cash": { - "pruning": "-", - "s": "50002", - "t": "50001", - "version": "1.4.1" - }, - "taxplorer.loping.net": { - "pruning": "-", - "s": "40002", - "t": "40001", - "version": "1.4.1" - } -} diff --git a/electroncash/servers_testnet.json b/electroncash/servers_testnet.json index 8c394a34820b..0a3cc335c5af 100644 --- a/electroncash/servers_testnet.json +++ b/electroncash/servers_testnet.json @@ -2,6 +2,11 @@ "bch0.kister.net": { "s": "51002" }, + "kisternet5tgeekwidrj7r7yd3n2l5j7y72b74y6xu3q2b6xdjrte6id.onion": { + "s": "51002", + "t": "51001", + "display": "bch0.kister.net" + }, "blackie.c3-soft.com": { "s": "60002" }, @@ -24,6 +29,9 @@ "s": "50004", "t": "50003" }, + "electrs.electroncash.de": { + "s": "60002" + }, "tbch.loping.net": { "s": "60002", "t": "60001" diff --git a/electroncash/servers_testnet4.json b/electroncash/servers_testnet4.json index 859301522033..58ebd946bfc9 100644 --- a/electroncash/servers_testnet4.json +++ b/electroncash/servers_testnet4.json @@ -12,7 +12,7 @@ "t": "62001", "display": "tbch4.loping.net" }, - "electroncash.de": { + "electroncash.de": { "t": "54003", "s": "54004" }, diff --git a/electroncash/simple_config.py b/electroncash/simple_config.py index 8f765f885c06..2b2240176db4 100644 --- a/electroncash/simple_config.py +++ b/electroncash/simple_config.py @@ -4,6 +4,7 @@ import time import os import stat +from decimal import Decimal as PyDecimal from . import util from copy import deepcopy @@ -52,7 +53,6 @@ def __init__(self, options=None, read_user_config_function=None, self.fee_estimates = {} self.fee_estimates_last_updated = {} self.last_time_fee_estimates_requested = 0 # zero ensures immediate fees - self.migrated_from_taxcoin_remove_keys = None # The following two functions are there for dependency injection when # testing. @@ -75,10 +75,6 @@ def __init__(self, options=None, read_user_config_function=None, if not self.user_config: # avoid new config getting upgraded self.user_config = {'config_version': FINAL_CONFIG_VERSION} - elif self.migrated_from_taxcoin_remove_keys: - for k in self.migrated_from_taxcoin_remove_keys: - self.user_config.pop(k, None) - self.migrated_from_taxcoin_remove_keys = None # config "upgrade" - CLI options self.rename_config_keys( @@ -108,27 +104,6 @@ def electrum_path(self): elif self.get('scalenet'): path = os.path.join(path, 'scalenet') make_dir(path) - elif self.get('taxcoin'): - # taxcoin support + migrate settings over - taxcoin_path = os.path.join(path, 'taxcoin') - if not os.path.exists(taxcoin_path) and os.path.exists(path): - make_dir(path) - try: - # just copy wallets/ & config - tax_wpath = os.path.join(taxcoin_path, "wallets") - shutil.copytree(os.path.join(path, "wallets"), tax_wpath, symlinks=True) - shutil.copy2(os.path.join(path, "config"), taxcoin_path, follow_symlinks=True) - # delete some keys so that user doesn't open up non-taxcoin wallets. - self.migrated_from_taxcoin_remove_keys = [ - 'server', 'recently_open', 'gui_last_wallet', 'gui_last_wallet_slp', 'server_blacklist', - 'server_whitelist_added', 'server_whitelist_removed', 'auto_connect', 'currency', 'fiat_address', - 'use_exchange_rate', 'history_rates', 'use_exchange' - ] - except OSError: - pass - path = taxcoin_path - - obsolete_file = os.path.join(path, 'recent_servers') if os.path.exists(obsolete_file): @@ -224,6 +199,8 @@ def is_modifiable(self, key): return key not in self.cmdline_options def save_user_config(self): + if self.get('forget_config'): + return if not self.path: return path = os.path.join(self.path, "config") @@ -351,7 +328,11 @@ def has_custom_fee_rate(self): return i >= 0 def estimate_fee(self, size): - return int(self.fee_per_kb() * size / 1000.) + return self.estimate_fee_for_feerate(self.fee_per_kb(), size) + + @classmethod + def estimate_fee_for_feerate(cls, fee_per_kb, size): + return int(PyDecimal(fee_per_kb) * PyDecimal(size) / 1000) def update_fee_estimates(self, key, value): self.fee_estimates[key] = value diff --git a/electroncash/storage.py b/electroncash/storage.py index 85159aa1f375..69b5528c3cbf 100644 --- a/electroncash/storage.py +++ b/electroncash/storage.py @@ -97,7 +97,7 @@ def load_data(self, s): d = ast.literal_eval(s) labels = d.get('labels', {}) except Exception as e: - raise IOError("Cannot read wallet file '%s'" % self.path) + raise IOError("Cannot read wallet file '%s': %s" % (self.path, e)) self.data = {} for key, value in d.items(): try: diff --git a/electroncash/tests/test_bitcoin.py b/electroncash/tests/test_bitcoin.py index 1e8cb3320c7c..daf36bdfac2d 100644 --- a/electroncash/tests/test_bitcoin.py +++ b/electroncash/tests/test_bitcoin.py @@ -8,11 +8,11 @@ generator_secp256k1, point_to_ser, public_key_to_p2pkh, EC_KEY, bip32_root, bip32_public_derivation, bip32_private_derivation, pw_encode, pw_decode, Hash, public_key_from_private_key, address_from_private_key, is_private_key, - xpub_from_xprv, var_int, op_push, regenerate_key, verify_message, + xpub_from_xprv, var_int, op_push, push_script, regenerate_key, verify_message, deserialize_privkey, serialize_privkey, is_minikey, is_compressed, is_xpub, - xpub_type, is_xprv, is_bip32_derivation, Bip38Key) + xpub_type, is_xprv, is_bip32_derivation, Bip38Key, OpCodes) from ..networks import set_mainnet, set_testnet -from ..util import bfh +from ..util import bfh, bh2u try: import ecdsa @@ -154,7 +154,7 @@ def test_hash(self): def test_var_int(self): for i in range(0xfd): - self.assertEqual(var_int(i), "{:02x}".format(i) ) + self.assertEqual(var_int(i), "{:02x}".format(i)) self.assertEqual(var_int(0xfd), "fdfd00") self.assertEqual(var_int(0xfe), "fdfe00") @@ -173,14 +173,45 @@ def test_op_push(self): self.assertEqual(op_push(0x4b), '4b') self.assertEqual(op_push(0x4c), '4c4c') self.assertEqual(op_push(0xfe), '4cfe') - self.assertEqual(op_push(0xff), '4dff00') + self.assertEqual(op_push(0xff), '4cff') self.assertEqual(op_push(0x100), '4d0001') self.assertEqual(op_push(0x1234), '4d3412') self.assertEqual(op_push(0xfffe), '4dfeff') - self.assertEqual(op_push(0xffff), '4effff0000') + self.assertEqual(op_push(0xffff), '4dffff') self.assertEqual(op_push(0x10000), '4e00000100') self.assertEqual(op_push(0x12345678), '4e78563412') + def test_push_script(self): + # https://github.com/bitcoin/bips/blob/master/bip-0062.mediawiki#push-operators + self.assertEqual(push_script(''), bh2u(bytes([OpCodes.OP_0]))) + self.assertEqual(push_script('07'), bh2u(bytes([OpCodes.OP_7]))) + self.assertEqual(push_script('10'), bh2u(bytes([OpCodes.OP_16]))) + self.assertEqual(push_script('81'), bh2u(bytes([OpCodes.OP_1NEGATE]))) + self.assertEqual(push_script('11'), '0111') + self.assertEqual(push_script(75 * '42'), '4b' + 75 * '42') + self.assertEqual(push_script(76 * '42'), bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('4c' + 76 * '42'))) + self.assertEqual(push_script(100 * '42'), bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('64' + 100 * '42'))) + self.assertEqual(push_script(255 * '42'), bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('ff' + 255 * '42'))) + self.assertEqual(push_script(256 * '42'), bh2u(bytes([OpCodes.OP_PUSHDATA2]) + bfh('0001' + 256 * '42'))) + self.assertEqual(push_script(520 * '42'), bh2u(bytes([OpCodes.OP_PUSHDATA2]) + bfh('0802' + 520 * '42'))) + # We also optionally support pushing non-minimally (for OP_RETURN "scripts") + self.assertEqual(push_script('', minimal=False), bh2u(bytes([OpCodes.OP_0]))) + self.assertEqual(push_script('07', minimal=False), '0107') + self.assertEqual(push_script('10', minimal=False), '0110') + self.assertEqual(push_script('81', minimal=False), '0181') + self.assertEqual(push_script('11', minimal=False), '0111') + self.assertEqual(push_script(75 * '42', minimal=False), '4b' + 75 * '42') + self.assertEqual(push_script(76 * '42', minimal=False), + bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('4c' + 76 * '42'))) + self.assertEqual(push_script(100 * '42', minimal=False), + bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('64' + 100 * '42'))) + self.assertEqual(push_script(255 * '42', minimal=False), + bh2u(bytes([OpCodes.OP_PUSHDATA1]) + bfh('ff' + 255 * '42'))) + self.assertEqual(push_script(256 * '42', minimal=False), + bh2u(bytes([OpCodes.OP_PUSHDATA2]) + bfh('0001' + 256 * '42'))) + self.assertEqual(push_script(520 * '42', minimal=False), + bh2u(bytes([OpCodes.OP_PUSHDATA2]) + bfh('0802' + 520 * '42'))) + class Test_bitcoin_testnet(unittest.TestCase): diff --git a/electroncash/tests/test_blockchain.py b/electroncash/tests/test_blockchain.py index a8d242a8b373..63f3b651fb86 100644 --- a/electroncash/tests/test_blockchain.py +++ b/electroncash/tests/test_blockchain.py @@ -74,3 +74,44 @@ def test_retargetting(self): # MTP(1010) is TimeStamp(1005), MTP(1004) is TimeStamp(999) hdr = {'block_height': block['block_height'] + 1} self.assertEqual(chain.get_bits(hdr, chunk), 0x1801b553) + + def test_target_to_bits(self): + # https://github.com/bitcoin/bitcoin/blob/7fcf53f7b4524572d1d0c9a5fdc388e87eb02416/src/arith_uint256.h#L269 + self.assertEqual(0x05123456, bc.target_to_bits(0x1234560000)) + self.assertEqual(0x0600c0de, bc.target_to_bits(0xc0de000000)) + + # tests from https://github.com/bitcoin/bitcoin/blob/a7d17daa5cd8bf6398d5f8d7e77290009407d6ea/src/test/arith_uint256_tests.cpp#L411 + tuples = ( + (0, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x00123456, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x01003456, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x02000056, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x03000000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x04000000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x00923456, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x01803456, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x02800056, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x03800000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x04800000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0), + (0x01123456, 0x0000000000000000000000000000000000000000000000000000000000000012, 0x01120000), + (0x02123456, 0x0000000000000000000000000000000000000000000000000000000000001234, 0x02123400), + (0x03123456, 0x0000000000000000000000000000000000000000000000000000000000123456, 0x03123456), + (0x04123456, 0x0000000000000000000000000000000000000000000000000000000012345600, 0x04123456), + (0x05009234, 0x0000000000000000000000000000000000000000000000000000000092340000, 0x05009234), + (0x20123456, 0x1234560000000000000000000000000000000000000000000000000000000000, 0x20123456), + ) + for nbits1, target, nbits2 in tuples: + with self.subTest(original_compact_nbits=nbits1.to_bytes(length=4, byteorder="big").hex()): + num = bc.bits_to_target(nbits1) + self.assertEqual(target, num) + self.assertEqual(nbits2, bc.target_to_bits(num)) + + # Make sure that we don't generate compacts with the 0x00800000 bit set + self.assertEqual(0x02008000, bc.target_to_bits(0x80)) + + with self.assertRaises(Exception): # target cannot be negative + bc.bits_to_target(0x01fedcba) + with self.assertRaises(Exception): # target cannot be negative + bc.bits_to_target(0x04923456) + with self.assertRaises(Exception): # overflow + bc.bits_to_target(0xff123456) diff --git a/electroncash/tor/controller.py b/electroncash/tor/controller.py index 4147c42d931a..a5ae759ce0cf 100644 --- a/electroncash/tor/controller.py +++ b/electroncash/tor/controller.py @@ -37,12 +37,20 @@ import stem.control import stem -from .. import util +from .. import util, version from ..util import PrintError from ..utils import Event from ..simple_config import SimpleConfig +# Python 3.10 workaround for stem package which is using collections.Iterable (removed in 3.10) +if sys.version_info >= (3, 10): + if hasattr(stem, '__version__') and version.parse_package_version(stem.__version__)[:2] <= (1, 8): + import collections.abc + # monkey-patch collections.Iterable back since stem.control expects to see this name + stem.control.collections.Iterable = collections.abc.Iterable + + _TOR_ENABLED_KEY = 'tor_enabled' _TOR_ENABLED_DEFAULT = False diff --git a/electroncash/transaction.py b/electroncash/transaction.py index 4752d25843be..ea8f5eb2116e 100644 --- a/electroncash/transaction.py +++ b/electroncash/transaction.py @@ -176,11 +176,14 @@ def short_hex(bytes): def match_decoded(decoded, to_match): if len(decoded) != len(to_match): - return False; + return False for i in range(len(decoded)): - if to_match[i] == opcodes.OP_PUSHDATA4 and decoded[i][0] <= opcodes.OP_PUSHDATA4 and decoded[i][0]>0: - continue # Opcodes below OP_PUSHDATA4 all just push data onto stack, and are equivalent. - if to_match[i] != decoded[i][0]: + op = decoded[i][0] + if to_match[i] == opcodes.OP_PUSHDATA4 and op <= opcodes.OP_PUSHDATA4 and op > 0: + # Opcodes below OP_PUSHDATA4 just push data onto stack, and are equivalent. + # Note we explicitly don't match OP_0, OP_1 through OP_16 and OP1_NEGATE here + continue + if to_match[i] != op: return False return True @@ -358,19 +361,14 @@ def deserialize(raw): # pay & redeem scripts - - - def multisig_script(public_keys, m): n = len(public_keys) assert n <= 15 assert m <= n - op_m = format(opcodes.OP_1 + m - 1, 'x') - op_n = format(opcodes.OP_1 + n - 1, 'x') - keylist = [op_push(len(k)//2) + k for k in public_keys] - return op_m + ''.join(keylist) + op_n + 'ae' - - + op_m = push_script_bytes(bytes([m])).hex() + op_n = push_script_bytes(bytes([n])).hex() + keylist = [push_script(k) for k in public_keys] + return op_m + ''.join(keylist) + op_n + bytes([opcodes.OP_CHECKMULTISIG]).hex() class Transaction: @@ -417,6 +415,12 @@ def __init__(self, raw, sign_schnorr=False): # there! self.ephemeral = dict() + def is_memory_compact(self): + """Returns True if the tx is stored in memory only as self.raw (serialized) and has no deserialized data + structures currently in memory. """ + return (self.raw is not None + and self._inputs is None and self._outputs is None and self.locktime == 0 and self.version == 1) + def set_sign_schnorr(self, b): self._sign_schnorr = b diff --git a/electroncash/version.py b/electroncash/version.py index c9ee76ac91dd..f2b9a22f8efc 100644 --- a/electroncash/version.py +++ b/electroncash/version.py @@ -1,4 +1,4 @@ -PACKAGE_VERSION = '4.2.4' # version of the client package +PACKAGE_VERSION = '4.2.6' # version of the client package PROTOCOL_VERSION = '1.4' # protocol version requested # The hash of the Electrum mnemonic seed must begin with this diff --git a/electroncash/wallet.py b/electroncash/wallet.py index c5887a01ec3e..7994451533f2 100644 --- a/electroncash/wallet.py +++ b/electroncash/wallet.py @@ -44,7 +44,7 @@ from .i18n import ngettext from .util import (NotEnoughFunds, ExcessiveFee, PrintError, UserCancelled, profiler, format_satoshis, format_time, - finalization_print_error, to_string) + finalization_print_error, to_string, TimeoutException) from .address import Address, Script, ScriptOutput, PublicKey, OpCodes from .bitcoin import * @@ -67,7 +67,7 @@ from . import paymentrequest -from .paymentrequest import InvoiceStore, PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED +from .paymentrequest import InvoiceStore, PR_PAID, PR_UNCONFIRMED, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED from .contacts import Contacts from . import cashacct from . import slp @@ -663,6 +663,7 @@ def add_verified_tx(self, tx_hash, info, header): height, conf, timestamp = self.get_tx_height(tx_hash) self.cashacct.add_verified_tx_hook(tx_hash, info, header) self.network.trigger_callback('verified2', self, tx_hash, height, conf, timestamp) + self._update_request_statuses_touched_by_tx(tx_hash) def verification_failed(self, tx_hash, reason): ''' TODO: Notify gui of this if it keeps happening, try a different @@ -695,6 +696,8 @@ def undo_verifications(self, blockchain, height): if txs: self.cashacct.undo_verifications_hook(txs) if txs: self._addr_bal_cache = {} # this is probably not necessary -- as the receive_history_callback will invalidate bad cache items -- but just to be paranoid we clear the whole balance cache on reorg anyway as a safety measure + for tx_hash in txs: + self._update_request_statuses_touched_by_tx(tx_hash) return txs def get_local_height(self): @@ -1459,6 +1462,12 @@ def remove_transaction(self, tx_hash): def receive_tx_callback(self, tx_hash, tx, tx_height): self.add_transaction(tx_hash, tx) self.add_unverified_tx(tx_hash, tx_height) + self._update_request_statuses_touched_by_tx(tx_hash) + + def _update_request_statuses_touched_by_tx(self, tx_hash): + tx = self.transactions.get(tx_hash) + if tx is None: + return if self.network and self.network.callback_listener_count("payment_received") > 0: for _, addr, _ in tx.outputs(): status = self.get_request_status(addr) # returns PR_UNKNOWN quickly if addr has no requests, otherwise returns tuple @@ -1901,6 +1910,8 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, cha # Fee estimator if fixed_fee is None: fee_estimator = config.estimate_fee + elif callable(fixed_fee): + fee_estimator = fixed_fee else: fee_estimator = lambda size: fixed_fee @@ -1976,7 +1987,6 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, cha sats_per_byte=fee_in_satoshis/tx_in_bytes if (sats_per_byte > 50): raise ExcessiveFee() - return # Sort the inputs and outputs deterministically tx.BIP_LI01_sort() @@ -2167,7 +2177,11 @@ def stop_pruned_txo_cleaner_thread(self): # a network call and it will eventually exit. t.join(timeout=3.0) - def wait_until_synchronized(self, callback=None): + def wait_until_synchronized(self, callback=None, *, timeout=None): + tstart = time.time() + def check_timed_out(): + if timeout is not None and time.time() - tstart > timeout: + raise TimeoutException() def wait_for_wallet(): self.set_up_to_date(False) while not self.is_up_to_date(): @@ -2178,12 +2192,14 @@ def wait_for_wallet(): len(self.addresses(True))) callback(msg) time.sleep(0.1) + check_timed_out() def wait_for_network(): while not self.network.is_connected(): if callback: msg = "%s \n" % (_("Connecting...")) callback(msg) time.sleep(0.1) + check_timed_out() # wait until we are connected, because the user # might have selected another server if self.network: @@ -2216,27 +2232,6 @@ def address_is_old(self, address, age_limit=2): break # ok, it's old. not need to keep looping return age > age_limit - def cpfp(self, tx, fee, sign_schnorr=None): - ''' sign_schnorr is a bool or None for auto ''' - sign_schnorr = self.is_schnorr_enabled() if sign_schnorr is None else bool(sign_schnorr) - txid = tx.txid() - for i, o in enumerate(tx.outputs()): - otype, address, value = o - if otype == TYPE_ADDRESS and self.is_mine(address): - break - else: - return - coins = self.get_addr_utxo(address) - item = coins.get(txid+':%d'%i) - if not item: - return - self.add_input_info(item) - inputs = [item] - outputs = [(TYPE_ADDRESS, address, value - fee)] - locktime = self.get_local_height() - # note: no need to call tx.BIP_LI01_sort() here - single input/output - return Transaction.from_io(inputs, outputs, locktime=locktime, sign_schnorr=sign_schnorr) - def add_input_info(self, txin): address = txin['address'] if self.is_mine(address): @@ -2366,7 +2361,7 @@ def get_payment_status(self, address, amount): info = self.verified_tx.get(txid) if info: tx_height, timestamp, pos = info - conf = local_height - tx_height + conf = max(local_height - tx_height + 1, 0) else: conf = 0 l.append((conf, v)) @@ -2438,7 +2433,12 @@ def get_request_status(self, key): conf = None if amount: paid, conf = self.get_payment_status(address, amount) - status = PR_PAID if paid else PR_UNPAID + if not paid: + status = PR_UNPAID + elif conf == 0: + status = PR_UNCONFIRMED + else: + status = PR_PAID if status == PR_UNPAID and expiration is not None and time.time() > timestamp + expiration: status = PR_EXPIRED else: diff --git a/electroncash/web.py b/electroncash/web.py index 47292787589c..66637f962e62 100644 --- a/electroncash/web.py +++ b/electroncash/web.py @@ -40,9 +40,6 @@ DEFAULT_EXPLORER = "Blockchair.com" mainnet_block_explorers = { - 'Bitcoin.com': ('https://explorer.bitcoin.com/bch', - Address.FMT_CASHADDR, - {'tx': 'tx', 'addr': 'address', 'block' : 'block'}), 'Blockchair.com': ('https://blockchair.com/bitcoin-cash', Address.FMT_CASHADDR, {'tx': 'transaction', 'addr': 'address', 'block' : 'block'}), @@ -69,12 +66,9 @@ {'tx': 'tx', 'addr': 'address', 'block': 'block-height'}), } -DEFAULT_EXPLORER_TESTNET = 'Bitcoin.com' +DEFAULT_EXPLORER_TESTNET = 'Blockchain.com' testnet_block_explorers = { - 'Bitcoin.com' : ('https://explorer.bitcoin.com/tbch', - Address.FMT_LEGACY, # For some reason testnet expects legacy and fails on bchtest: addresses. - {'tx': 'tx', 'addr': 'address', 'block' : 'block'}), 'BlockExplorer.one': ('https://blockexplorer.one/bch/testnet', Address.FMT_CASHADDR, {'tx': 'tx', 'addr': 'address', 'block' : 'blockHash'}), @@ -108,13 +102,6 @@ {'tx': 'tx', 'addr': 'address', 'block': 'block-height'}), } -DEFAULT_EXPLORER_TAXCOIN = 'The Taxplorer' - -taxcoin_block_explorers = { - 'The Taxplorer': ('https://taxplorer.loping.net', - Address.FMT_CASHADDR, - {'tx': 'tx', 'addr': 'address', 'block': 'block-height'}), -} def BE_info(): if networks.net is networks.TestNet: @@ -123,8 +110,6 @@ def BE_info(): return testnet4_block_explorers elif networks.net is networks.ScaleNet: return scalenet_block_explorers - elif networks.net is networks.TaxCoinNet: - return taxcoin_block_explorers return mainnet_block_explorers def BE_tuple(config): @@ -140,8 +125,6 @@ def BE_default_explorer(): return DEFAULT_EXPLORER_TESTNET4 elif networks.net is networks.ScaleNet: return DEFAULT_EXPLORER_SCALENET - elif networks.net is networks.TaxCoinNet: - return DEFAULT_EXPLORER_TAXCOIN return DEFAULT_EXPLORER def BE_from_config(config): diff --git a/electroncash_gui/qt/__init__.py b/electroncash_gui/qt/__init__.py index 8846a22310be..323863d8739c 100644 --- a/electroncash_gui/qt/__init__.py +++ b/electroncash_gui/qt/__init__.py @@ -761,7 +761,7 @@ def _start_auto_update_timer(self, *, first_run = False): interval = 10.0*1e3 # do it very soon (in 10 seconds) else: interval = 4.0*3600.0*1e3 # once every 4 hours (in ms) - self.update_checker_timer.start(interval) + self.update_checker_timer.start(int(interval)) self.print_error("Auto update check: interval set to {} seconds".format(interval//1e3)) def _stop_auto_update_timer(self): @@ -1008,6 +1008,9 @@ def clean_up(): event = QEvent(QEvent.Clipboard) self.app.sendEvent(self.app.clipboard(), event) self.tray.hide() + if self.nd: + self.nd.deleteLater() + self.nd = None self.app.aboutToQuit.connect(clean_up) Exception_Hook(self.config) # This wouldn't work anyway unless the app event loop is active, so we must install it once here and no earlier. diff --git a/electroncash_gui/qt/data/ard_mone.mp3 b/electroncash_gui/qt/data/ard_mone.mp3 deleted file mode 100644 index 3eebbf02953c..000000000000 Binary files a/electroncash_gui/qt/data/ard_mone.mp3 and /dev/null differ diff --git a/electroncash_gui/qt/history_list.py b/electroncash_gui/qt/history_list.py index e66207adb400..e8a2cd8fa092 100644 --- a/electroncash_gui/qt/history_list.py +++ b/electroncash_gui/qt/history_list.py @@ -161,6 +161,7 @@ def on_update(self): if value and value < 0: item.setForeground(3, self.withdrawalBrush) item.setForeground(4, self.withdrawalBrush) + item.setForeground(6, self.withdrawalBrush) item.setData(0, Qt.UserRole, tx_hash) self.addTopLevelItem(item, tx_hash) if current_tx == tx_hash: @@ -263,10 +264,6 @@ def create_menu(self, position): lambda: self.currentItem() and self.editItem(self.currentItem(), column)) label = self.wallet.get_label(tx_hash) or None menu.addAction(_("&Details"), lambda: self.parent.show_transaction(tx, label)) - if is_unconfirmed and tx: - child_tx = self.wallet.cpfp(tx, 0) - if child_tx: - menu.addAction(_("Child pays for parent"), lambda: self.parent.cpfp(tx, child_tx)) if pr_key: menu.addAction(self.invoiceIcon, _("View invoice"), lambda: self.parent.show_invoice(pr_key)) if tx_URL: diff --git a/electroncash_gui/qt/installwizard.py b/electroncash_gui/qt/installwizard.py index d04b0377fbdd..6986d59a4163 100644 --- a/electroncash_gui/qt/installwizard.py +++ b/electroncash_gui/qt/installwizard.py @@ -1,6 +1,8 @@ # -*- mode: python3 -*- import os +import random import sys +import tempfile import threading import traceback import weakref @@ -9,10 +11,13 @@ from PyQt5.QtGui import * from PyQt5.QtWidgets import * -from electroncash import Wallet, WalletStorage -from electroncash.util import UserCancelled, InvalidPassword, finalization_print_error + +from electroncash import keystore, Wallet, WalletStorage +from electroncash.network import Network +from electroncash.util import UserCancelled, InvalidPassword, finalization_print_error, TimeoutException from electroncash.base_wizard import BaseWizard from electroncash.i18n import _ +from electroncash.wallet import Standard_Wallet from .seed_dialog import SeedLayout, KeysLayout from .network_dialog import NetworkChoiceLayout @@ -24,6 +29,7 @@ class GoBack(Exception): pass + MSG_GENERATING_WAIT = _("Electron Cash is generating your addresses, please wait...") MSG_ENTER_ANYTHING = _("Please enter a seed phrase, a master key, a list of " "Bitcoin addresses, or a list of private keys") @@ -73,7 +79,6 @@ def paintEvent(self, event): qp.end() - def wizard_dialog(func): def func_wrapper(*args, **kwargs): run_next = kwargs['run_next'] @@ -93,7 +98,6 @@ def func_wrapper(*args, **kwargs): return func_wrapper - # WindowModalDialog must come first as it overrides show_error class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): @@ -284,8 +288,6 @@ def on_filename(filename): self.wallet = Wallet(self.storage) return self.wallet, password - - def finished(self): """Called in hardware client wrapper, in order to close popups.""" return @@ -309,7 +311,7 @@ def set_layout(self, layout, title=None, next_enabled=True): QWidget().setLayout(prior_layout) self.main_widget.setLayout(layout) self.back_button.setEnabled(True) - self.next_button.setEnabled(next_enabled) + self.next_button.setEnabled(bool(next_enabled)) if next_enabled: self.next_button.setFocus() self.main_widget.setVisible(True) @@ -414,7 +416,8 @@ def request_password(self, run_next): cannot go back, and instead the user can only cancel.""" return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW) - def _add_extra_button_to_layout(self, extra_button, layout): + @staticmethod + def _add_extra_button_to_layout(extra_button, layout): if (not isinstance(extra_button, (list, tuple)) or not len(extra_button) == 2): return @@ -427,7 +430,6 @@ def _add_extra_button_to_layout(self, extra_button, layout): layout.addLayout(hbox) but.clicked.connect(but_action) - @wizard_dialog def confirm_dialog(self, title, message, run_next, extra_button=None): self.confirm(message, title, extra_button=extra_button) @@ -475,7 +477,6 @@ def choice_dialog(self, title, message, choices, run_next, extra_button=None): action = c_values[clayout.selected_index()] return action - def query_choice(self, msg, choices): """called by hardware wallets""" clayout = ChoicesLayout(msg, choices) @@ -491,10 +492,42 @@ def line_dialog(self, run_next, title, message, default, test, warning=''): line = QLineEdit() line.setText(default) def f(text): - self.next_button.setEnabled(test(text)) + self.next_button.setEnabled(bool(test(text))) + line.textEdited.connect(f) + vbox.addWidget(line) + vbox.addWidget(WWLabel(warning)) + self.exec_layout(vbox, title, next_enabled=test(default)) + return ' '.join(line.text().split()) + + @wizard_dialog + def derivation_path_dialog(self, run_next, title, message, default, test, warning='', seed='', scannable=False): + def on_derivation_scan(derivation_line, seed): + derivation_scan_dialog = DerivationDialog(self, seed, DerivationPathScanner.DERIVATION_PATHS) + destroyed_print_error(derivation_scan_dialog) + selected_path = derivation_scan_dialog.get_selected_path() + if selected_path: + derivation_line.setText(selected_path) + derivation_scan_dialog.deleteLater() + + vbox = QVBoxLayout() + vbox.addWidget(WWLabel(message)) + line = QLineEdit() + line.setText(default) + def f(text): + self.next_button.setEnabled(bool(test(text))) line.textEdited.connect(f) vbox.addWidget(line) vbox.addWidget(WWLabel(warning)) + + if scannable: + hbox = QHBoxLayout() + hbox.setContentsMargins(12,24,12,12) + but = QPushButton(_("Scan Derivation Paths...")) + hbox.addStretch(1) + hbox.addWidget(but) + vbox.addLayout(hbox) + but.clicked.connect(lambda: on_derivation_scan(line, seed)) + self.exec_layout(vbox, title, next_enabled=test(default)) return ' '.join(line.text().split()) @@ -512,11 +545,11 @@ def show_xpub_dialog(self, xpub, run_next): def init_network(self, network): message = _("Electron Cash communicates with remote servers to get " - "information about your transactions and addresses. The " - "servers all fulfil the same purpose only differing in " - "hardware. In most cases you simply want to let Electron Cash " - "pick one at random. However if you prefer feel free to " - "select a server manually.") + "information about your transactions and addresses. The " + "servers all fulfil the same purpose only differing in " + "hardware. In most cases you simply want to let Electron Cash " + "pick one at random. However if you prefer feel free to " + "select a server manually.") choices = [_("Auto connect"), _("Select server manually")] title = _("How do you want to connect to a server? ") clayout = ChoicesLayout(message, choices) @@ -569,6 +602,7 @@ def on_n(n): return (m, n) linux_hw_wallet_support_dialog = None + def on_hw_wallet_support(self): ''' Overrides base wizard's noop impl. ''' if sys.platform.startswith("linux"): @@ -584,22 +618,183 @@ def on_hw_wallet_support(self): else: self.show_error("Linux only facility. FIXME!") - def showEvent(self, event): - ret = super().showEvent(event) - from electroncash import networks - if networks.net is networks.TaxCoinNet and not self.config.get("have_shown_taxcoin_dialog"): - self.config.set_key("have_shown_taxcoin_dialog", True) - weakSelf = weakref.ref(self) - def do_dialog(): - slf = weakSelf() - if not slf: - return - QMessageBox.information(slf, _("Electron Cash - Tax Coin"), - _("For TaxCoin, your existing wallet files and configuration have " - "been duplicated in the subdirectory taxcoin/ within your Electron Cash " - "directory.\n\n" - "To use TaxCoin, you should select a server manually, and then choose one of " - "the starred servers.\n\n" - "After selecting a server, select a wallet file to open.")) - QTimer.singleShot(10, do_dialog) - return ret + +class DerivationPathScanner(QThread): + + DERIVATION_PATHS = [ + "m/44'/145'/0'", + "m/44'/0'/0'", + "m/44'/245'/0'", + "m/144'/44'/0'", + "m/144'/0'/0'", + "m/44'/0'/0'/0", + "m/0", + "m/0'", + "m/0'/0", + "m/0'/0'", + "m/0'/0'/0'", + "m/44'/145'/0'/0", + "m/44'/245'/0", + "m/44'/245'/0'/0", + "m/49'/0'/0'", + "m/84'/0'/0'", + ] + + def __init__(self, parent, seed, seed_type, config, update_table_cb): + QThread.__init__(self, parent) + self.update_table_cb = update_table_cb + self.seed = seed + self.seed_type = seed_type + self.config = config + self.aborting = False + + def notify_offline(self): + for i, p in enumerate(self.DERIVATION_PATHS): + self.update_table_cb(i, _('Offline')) + + def notify_timedout(self, i): + self.update_table_cb(i, _('Timed out')) + + def run(self): + network = Network.get_instance() + if not network: + self.notify_offline() + return + + for i, p in enumerate(self.DERIVATION_PATHS): + if self.aborting: + return + k = keystore.from_seed(self.seed, '', derivation=p, seed_type=self.seed_type) + p_safe = p.replace('/', '_').replace("'", 'h') + storage_path = os.path.join( + tempfile.gettempdir(), + p_safe + '_' + random.getrandbits(32).to_bytes(4, 'big').hex()[:8] + "_not_saved_" + ) + tmp_storage = WalletStorage(storage_path, in_memory_only=True) + tmp_storage.put('seed_type', self.seed_type) + tmp_storage.put('keystore', k.dump()) + wallet = Standard_Wallet(tmp_storage) + try: + wallet.start_threads(network) + wallet.synchronize() + wallet.print_error("Scanning", p) + synched = False + for ctr in range(25): + try: + wallet.wait_until_synchronized(timeout=1.0) + synched = True + except TimeoutException: + wallet.print_error(f'timeout try {ctr+1}/25') + if self.aborting: + return + if not synched: + wallet.print_error("Timeout on", p) + self.notify_timedout(i) + continue + while network.is_connecting(): + time.sleep(0.1) + if self.aborting: + return + num_tx = len(wallet.get_history()) + self.update_table_cb(i, str(num_tx)) + finally: + wallet.clear_history() + wallet.stop_threads() + + +class DerivationDialog(QDialog): + scan_result_signal = pyqtSignal(object, object) + + def __init__(self, parent, seed, paths): + QDialog.__init__(self, parent) + + self.seed = seed + self.seed_type = parent.seed_type + self.config = parent.config + self.max_seen = 0 + + self.setWindowTitle(_('Select Derivation Path')) + vbox = QVBoxLayout() + self.setLayout(vbox) + vbox.setContentsMargins(24, 24, 24, 24) + + self.label = QLabel(self) + vbox.addWidget(self.label) + + self.table = QTableWidget(self) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + self.table.setSortingEnabled(False) + self.table.setColumnCount(2) + self.table.setRowCount(len(paths)) + self.table.setHorizontalHeaderItem(0, QTableWidgetItem(_('Path'))) + self.table.setHorizontalHeaderItem(1, QTableWidgetItem(_('Transactions'))) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.table.setMinimumHeight(350) + + for row, d_path in enumerate(paths): + path_item = QTableWidgetItem(d_path) + path_item.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + self.table.setItem(row, 0, path_item) + transaction_count_item = QTableWidgetItem(_('Scanning...')) + transaction_count_item.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + self.table.setItem(row, 1, transaction_count_item) + + self.table.cellDoubleClicked.connect(self.accept) + self.table.selectRow(0) + vbox.addWidget(self.table) + ok_but = OkButton(self) + buts = Buttons(CancelButton(self), ok_but) + vbox.addLayout(buts) + vbox.addStretch(1) + ok_but.setEnabled(True) + self.scan_result_signal.connect(self.update_table) + self.t = None + + def set_scan_progress(self, n): + self.label.setText(_('Scanned {}/{}').format(n, len(DerivationPathScanner.DERIVATION_PATHS))) + + def kill_t(self): + if self.t and self.t.isRunning(): + self.t.aborting = True + self.t.wait(5000) + + def showEvent(self, e): + super().showEvent(e) + if e.isAccepted(): + self.kill_t() + self.t = DerivationPathScanner(self, self.seed, self.seed_type, self.config, self.update_table_cb) + self.max_seen = 0 + self.set_scan_progress(0) + self.t.start() + + def closeEvent(self, e): + super().closeEvent(e) + if e.isAccepted(): + self.kill_t() + + def update_table_cb(self, row, scan_result): + self.scan_result_signal.emit(row, scan_result) + + def update_table(self, row, scan_result): + self.set_scan_progress(row+1) + try: + num = int(scan_result) + if num > self.max_seen: + self.table.selectRow(row) + self.max_seen = num + except (ValueError, TypeError): + pass + self.table.item(row, 1).setText(scan_result) + + def get_selected_path(self): + path_to_return = None + if self.exec_(): + pathstr = self.table.selectionModel().selectedRows() + row = pathstr[0].row() + path_to_return = self.table.item(row, 0).text() + self.kill_t() + return path_to_return diff --git a/electroncash_gui/qt/main_window.py b/electroncash_gui/qt/main_window.py index 19409222a3ca..f7832376cecd 100644 --- a/electroncash_gui/qt/main_window.py +++ b/electroncash_gui/qt/main_window.py @@ -155,7 +155,6 @@ def __init__(self, gui_object, wallet): self.externalpluginsdialog = None self.hardwarewalletdialog = None self.require_fee_update = False - self.tx_sound = self.setup_tx_rcv_sound() self.cashaddr_toggled_signal = self.gui_object.cashaddr_toggled_signal # alias for backwards compatibility for plugins -- this signal used to live in each window and has since been refactored to gui-object where it belongs (since it's really an app-global setting) self.force_use_single_change_addr = None # this is set by the CashShuffle plugin to a single string that will go into the tool-tip explaining why this preference option is disabled (see self.settings_dialog) self.tl_windows = [] @@ -252,29 +251,6 @@ def add_optional_tab(tabs, tab, icon, description, name, default=True): gui_object.timer.timeout.connect(self.timer_actions) self.fetch_alias() - def setup_tx_rcv_sound(self): - """Used only in the 'ard moné edition""" - if networks.net is not networks.TaxCoinNet: - return - try: - import PyQt5.QtMultimedia - from PyQt5.QtCore import QUrl, QResource - from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent - fileName = os.path.join(os.path.dirname(__file__), "data", "ard_mone.mp3") - url = QUrl.fromLocalFile(fileName) - self.print_error("Sound effect: loading from", url.toLocalFile()) - player = QMediaPlayer(self) - player.setMedia(QMediaContent(url)) - player.setVolume(100) - self.print_error("Sound effect: regustered successfully") - return player - except Exception as e: - self.print_error("Sound effect: Failed:", str(e)) - return - - - - _first_shown = True def showEvent(self, event): super().showEvent(event) @@ -498,7 +474,7 @@ def watching_only_changed(self): self.password_menu.setEnabled(self.wallet.can_change_password()) self.import_privkey_menu.setVisible(self.wallet.can_import_privkey()) self.import_address_menu.setVisible(self.wallet.can_import_address()) - self.export_menu.setEnabled(self.wallet.can_export()) + self.export_menu.setEnabled(bool(self.wallet.can_export())) def warn_if_watching_only(self): if self.wallet.is_watching_only(): @@ -607,7 +583,7 @@ def update_recently_visited(self, filename): def loader(k): return lambda: gui_object.new_window(k) self.recently_visited_menu.addAction(b, loader(k)).setShortcut(QKeySequence("Ctrl+%d"%(i+1))) - self.recently_visited_menu.setEnabled(len(recent)) + self.recently_visited_menu.setEnabled(bool(len(recent))) def get_wallet_folder(self): return self.gui_object.get_wallet_folder() @@ -773,7 +749,7 @@ def show_about(self): QMessageBox.about(self, "Electron Cash", "

Electron Cash

" + _("Version") + f" {self.wallet.electrum_version}" + "

" + '

' + - _("Copyright © {year_start}-{year_end} Electron Cash LLC and the Electron Cash developers.").format(year_start=2017, year_end=2021) + + _("Copyright © {year_start}-{year_end} Electron Cash LLC and the Electron Cash developers.").format(year_start=2017, year_end=2022) + "

" + _("darkdetect for macOS © 2019 Alberto Sottile") + "

" "
" + '

' + @@ -968,7 +944,6 @@ def update_status(self): _("Balance: {amount_and_unit}").format( amount_and_unit=self.format_amount_and_units(c)) ] - if u: text_items.append(_("[{amount} unconfirmed]").format( amount=self.format_amount(u, True).strip())) @@ -1006,11 +981,10 @@ def update_status(self): self.tray.setToolTip("%s (%s)" % (text, self.wallet.basename())) self.balance_label.setText(text) - self.status_button.setIcon( icon ) + self.status_button.setIcon(icon) self.status_button.setStatusTip( status_tip ) run_hook('window_update_status', self) - def update_wallet(self): self.need_update.set() # will enqueue an _update_wallet() call in at most 0.5 seconds from now. @@ -1632,9 +1606,9 @@ def create_send_tab(self): self.max_button.setFixedWidth(140) self.max_button.setCheckable(True) grid.addWidget(self.max_button, 5, 3) - hbox = QHBoxLayout() + hbox = self.send_tab_extra_plugin_controls_hbox = QHBoxLayout() hbox.addStretch(1) - grid.addLayout(hbox, 5, 4) + grid.addLayout(hbox, 5, 4, 1, -1) msg = _('Bitcoin Cash transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\ + _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\ @@ -2876,7 +2850,7 @@ def create_status_bar(self): self.update_available_button.setVisible(bool(self.gui_object.new_version_available)) # if hidden now gets unhidden by on_update_available when a new version comes in self.lock_icon = QIcon() - self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog ) + self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog) sb.addPermanentWidget(self.password_button) self.addr_converter_button = StatusBarButton( @@ -2889,8 +2863,10 @@ def create_status_bar(self): self.addr_converter_button.setHidden(self.gui_object.is_cashaddr_status_button_hidden()) self.gui_object.cashaddr_status_button_hidden_signal.connect(self.addr_converter_button.setHidden) - sb.addPermanentWidget(StatusBarButton(QIcon(":icons/preferences.svg"), _("Preferences"), self.settings_dialog ) ) - self.seed_button = StatusBarButton(QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog ) + q_icon_prefs = QIcon(":icons/preferences.svg"), _("Preferences"), self.settings_dialog + sb.addPermanentWidget(StatusBarButton(*q_icon_prefs)) + q_icon_seed = QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog + self.seed_button = StatusBarButton(*q_icon_seed) sb.addPermanentWidget(self.seed_button) weakSelf = Weak.ref(self) gui_object = self.gui_object @@ -4421,7 +4397,7 @@ def on_usechange(x): if self.wallet.use_change != usechange_result: self.wallet.use_change = usechange_result self.wallet.storage.put('use_change', self.wallet.use_change) - multiple_cb.setEnabled(self.wallet.use_change) + multiple_cb.setEnabled(bool(self.wallet.use_change)) usechange_cb.stateChanged.connect(on_usechange) per_wallet_tx_widgets.append((usechange_cb, None)) @@ -4433,9 +4409,9 @@ def on_usechange(x): if isinstance(self.force_use_single_change_addr, str): multiple_cb.setToolTip(self.force_use_single_change_addr) else: - multuple_cb.setToolTip('') + multiple_cb.setToolTip('') else: - multiple_cb.setEnabled(self.wallet.use_change) + multiple_cb.setEnabled(bool(self.wallet.use_change)) multiple_cb.setToolTip('\n'.join([ _('In some cases, use up to 3 change addresses in order to break ' 'up large coin amounts and obfuscate the recipient address.'), @@ -4597,7 +4573,7 @@ def on_fiat_address(checked): fiat_widgets.append((fiat_address_checkbox, None)) else: - # For testnet(s) and for --taxcoin we do not support Fiat display + # For testnet(s) where we do not support Fiat display lbl = QLabel(_("Fiat display is not supported on this chain.")) lbl.setAlignment(Qt.AlignHCenter|Qt.AlignVCenter) f = lbl.font() @@ -4868,10 +4844,10 @@ def do_toggle(weakCb, name, i): cb = QCheckBox(descr['fullname']) weakCb = Weak.ref(cb) plugin_is_loaded = p is not None - cb_enabled = (not plugin_is_loaded and plugins.is_internal_plugin_available(name, self.wallet) - or plugin_is_loaded and p.can_user_disable()) + cb_enabled = bool(not plugin_is_loaded and plugins.is_internal_plugin_available(name, self.wallet) + or plugin_is_loaded and p.can_user_disable()) cb.setEnabled(cb_enabled) - cb.setChecked(plugin_is_loaded and p.is_enabled()) + cb.setChecked(bool(plugin_is_loaded and p.is_enabled())) grid.addWidget(cb, i, 0) enable_settings_widget(p, name, i) cb.clicked.connect(partial(do_toggle, weakCb, name, i)) @@ -4913,62 +4889,6 @@ def hardware_wallet_support(self): d.exec_() self.hardwarewalletdialog = None # allow python to GC - def cpfp(self, parent_tx, new_tx): - total_size = parent_tx.estimated_size() + new_tx.estimated_size() - d = WindowModalDialog(self.top_level_window(), _('Child Pays for Parent')) - vbox = QVBoxLayout(d) - msg = ( - "A CPFP is a transaction that sends an unconfirmed output back to " - "yourself, with a high fee. The goal is to have miners confirm " - "the parent transaction in order to get the fee attached to the " - "child transaction.") - vbox.addWidget(WWLabel(_(msg))) - msg2 = ("The proposed fee is computed using your " - "fee/kB settings, applied to the total size of both child and " - "parent transactions. After you broadcast a CPFP transaction, " - "it is normal to see a new unconfirmed transaction in your history.") - vbox.addWidget(WWLabel(_(msg2))) - grid = QGridLayout() - grid.addWidget(QLabel(_('Total size') + ':'), 0, 0) - grid.addWidget(QLabel(_('{total_size} bytes').format(total_size=total_size)), 0, 1) - max_fee = new_tx.output_value() - grid.addWidget(QLabel(_('Input amount') + ':'), 1, 0) - grid.addWidget(QLabel(self.format_amount(max_fee) + ' ' + self.base_unit()), 1, 1) - output_amount = QLabel('') - grid.addWidget(QLabel(_('Output amount') + ':'), 2, 0) - grid.addWidget(output_amount, 2, 1) - fee_e = BTCAmountEdit(self.get_decimal_point) - def f(x): - a = max_fee - fee_e.get_amount() - output_amount.setText((self.format_amount(a) + ' ' + self.base_unit()) if a else '') - fee_e.textChanged.connect(f) - fee = self.config.fee_per_kb() * total_size / 1000 - fee_e.setAmount(fee) - grid.addWidget(QLabel(_('Fee' + ':')), 3, 0) - grid.addWidget(fee_e, 3, 1) - def on_rate(dyn, pos, fee_rate): - fee = fee_rate * total_size / 1000 - fee = min(max_fee, fee) - fee_e.setAmount(fee) - fee_slider = FeeSlider(self, self.config, on_rate) - fee_slider.update() - grid.addWidget(fee_slider, 4, 1) - vbox.addLayout(grid) - vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) - result = d.exec_() - d.setParent(None) # So Python can GC - if not result: - return - fee = fee_e.get_amount() - if fee > max_fee: - self.show_error(_('Max fee exceeded')) - return - new_tx = self.wallet.cpfp(parent_tx, fee) - if new_tx is None: - self.show_error(_('CPFP no longer valid')) - return - self.show_transaction(new_tx) - def rebuild_history(self): if self.gui_object.warn_if_no_network(self): # Don't allow if offline mode. @@ -5375,6 +5295,3 @@ def process_notifs(self): .format(n_cashacct, ca_text)) else: parent.notify(_("New transaction: {}").format(ca_text)) - # Play the sound effect ('ard moné edition only) - if parent.tx_sound: - parent.tx_sound.play() diff --git a/electroncash_gui/qt/network_dialog.py b/electroncash_gui/qt/network_dialog.py index 342cde824950..76ce439a4669 100644 --- a/electroncash_gui/qt/network_dialog.py +++ b/electroncash_gui/qt/network_dialog.py @@ -28,6 +28,7 @@ import queue import socket +import weakref from functools import partial from PyQt5.QtGui import * @@ -49,11 +50,14 @@ protocol_names = ['TCP', 'SSL'] protocol_letters = 'ts' -class NetworkDialog(MessageBoxMixin, QDialog): + +class NetworkDialog(MessageBoxMixin, OnDestroyedMixin, QDialog): network_updated_signal = pyqtSignal() def __init__(self, network, config): QDialog.__init__(self) + OnDestroyedMixin.__init__(self) + self.weak_network = network and weakref.ref(network) self.setWindowTitle(_('Network')) self.setMinimumSize(500, 350) self.nlayout = NetworkChoiceLayout(self, network, config) @@ -74,13 +78,23 @@ def __init__(self, network, config): self.refresh_timer.timeout.connect(self.network_updated_signal.emit) self.refresh_timer.setInterval(500) + def on_destroyed(self, obj): + if self.is_destroyed: + return + OnDestroyedMixin.on_destroyed(self, obj) + network = self.weak_network and self.weak_network() + if network: + network.unregister_callback(self.on_network) + print_error("NetworkDialog: unregistered callback") + def jumpto(self, location : str): self.nlayout.jumpto(location) def on_network(self, event, *args): ''' This may run in network thread ''' #print_error("[NetworkDialog] on_network:",event,*args) - self.network_updated_signal.emit() # this enqueues call to on_update in GUI thread + if not self.is_destroyed: + self.network_updated_signal.emit() # this enqueues call to on_update in GUI thread @rate_limited(0.333) # limit network window updates to max 3 per second. More frequent isn't that useful anyway -- and on large wallets/big synchs the network spams us with events which we would rather collapse into 1 def on_update(self): @@ -159,7 +173,7 @@ def create_menu(self, position): menu.exec_(self.viewport().mapToGlobal(position)) def keyPressEvent(self, event): - if event.key() in [ Qt.Key_F2, Qt.Key_Return ]: + if event.key() in {Qt.Key_F2, Qt.Key_Return}: item, col = self.currentItem(), self.currentColumn() if item and col > -1: self.on_activated(item, col) @@ -390,10 +404,11 @@ def update(self, network, servers, protocol, use_tor): self.setAutoScroll(val) -class NetworkChoiceLayout(QObject, PrintError): +class NetworkChoiceLayout(QObject, OnDestroyedMixin, PrintError): def __init__(self, parent, network, config, wizard=False): - super().__init__(parent) + QObject.__init__(self, parent) + OnDestroyedMixin.__init__(self) self.network = network self.config = config self.protocol = None @@ -723,7 +738,7 @@ def jumpto(self, location : str): @in_main_thread def on_tor_port_changed(self, controller: TorController): - if not controller.active_socks_port or not controller.is_enabled() or not self.tor_use: + if self.is_destroyed or not controller.active_socks_port or not controller.is_enabled() or not self.tor_use: return # The Network class handles actually changing the port, we just @@ -741,7 +756,7 @@ def check_disable_proxy(self, b): # Disallow changing the proxy settings when Tor is in use b = False for w in [self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password]: - w.setEnabled(b) + w.setEnabled(bool(b)) def get_set_server_flags(self): return (self.config.is_modifiable('server'), @@ -927,11 +942,11 @@ def set_server(self, onion_hack=False): def set_proxy(self): host, port, protocol, proxy, auto_connect = self.network.get_parameters() if self.proxy_cb.isChecked(): - proxy = { 'mode':str(self.proxy_mode.currentText()).lower(), - 'host':str(self.proxy_host.text()), - 'port':str(self.proxy_port.text()), - 'user':str(self.proxy_user.text()), - 'password':str(self.proxy_password.text())} + proxy = {'mode':str(self.proxy_mode.currentText()).lower(), + 'host':str(self.proxy_host.text()), + 'port':str(self.proxy_port.text()), + 'user':str(self.proxy_user.text()), + 'password':str(self.proxy_password.text())} else: proxy = None self.network.set_parameters(host, port, protocol, proxy, auto_connect) @@ -980,6 +995,8 @@ def set_tor_enabled(self, enabled: bool): @in_main_thread def on_tor_status_changed(self, controller): + if self.is_destroyed: + return if controller.status == TorController.Status.ERRORED and self.tabs.isVisible(): tbname = self._tor_client_names[self.network.tor_controller.tor_binary_type] msg = _("The {tor_binary_name} client experienced an error or could not be started.").format(tor_binary_name=tbname) @@ -990,7 +1007,7 @@ def set_tor_socks_port(self): self.network.tor_controller.set_socks_port(socks_port) def on_custom_port_cb_click(self, b): - self.tor_socks_port.setEnabled(b) + self.tor_socks_port.setEnabled(bool(b)) if not b: self.tor_socks_port.setText("0") self.set_tor_socks_port() @@ -1059,15 +1076,18 @@ def on_clear_blacklist(self): return False -class TorDetector(QThread): +class TorDetector(QThread, OnDestroyedMixin): found_proxy = pyqtSignal(object) def __init__(self, parent, network): - super().__init__(parent) + QThread.__init__(self, parent) + OnDestroyedMixin.__init__(self) self.network = network self.network.tor_controller.active_port_changed.append_weak(self.on_tor_port_changed) def on_tor_port_changed(self, controller: TorController): + if self.is_destroyed: + return if controller.active_socks_port and self.isRunning(): self.stopQ.put('kick') diff --git a/electroncash_gui/qt/password_dialog.py b/electroncash_gui/qt/password_dialog.py index d47c42297775..fa07786c90e1 100644 --- a/electroncash_gui/qt/password_dialog.py +++ b/electroncash_gui/qt/password_dialog.py @@ -48,7 +48,7 @@ def check_password_strength(password): num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None caps = password != password.upper() and password != password.lower() extra = re.match("^[a-zA-Z0-9]*$", password) is None - score = len(password)*( n + caps + num + extra)/20 + score = len(password)*(n + caps + num + extra)/20 password_strength = {0:"Weak",1:"Medium",2:"Strong",3:"Very Strong"} return password_strength[min(3, int(score))] diff --git a/electroncash_gui/qt/paytoedit.py b/electroncash_gui/qt/paytoedit.py index a64f8042fff0..a63adf0267b7 100644 --- a/electroncash_gui/qt/paytoedit.py +++ b/electroncash_gui/qt/paytoedit.py @@ -68,7 +68,7 @@ def __init__(self, win): documentMargin = document.documentMargin() self.verticalMargins = margins.top() + margins.bottom() self.verticalMargins += self.frameWidth() * 2 - self.verticalMargins += documentMargin * 2 + self.verticalMargins += int(documentMargin * 2) self.heightMin = self.fontSpacing + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins diff --git a/electroncash_gui/qt/popup_widget.py b/electroncash_gui/qt/popup_widget.py index a49969b2e19a..11c567b503d5 100644 --- a/electroncash_gui/qt/popup_widget.py +++ b/electroncash_gui/qt/popup_widget.py @@ -40,7 +40,7 @@ def __init__(self, parent = None, timeout = None, delete_on_hide = True, self.pointerPos = self.LeftSide self._timer = None self.activation_hides = activation_hides - self.dark_mode = dark_mode + self.dark_mode = dark_mode and sys.platform.lower() != "darwin" #self.resize(200, 50) @@ -115,41 +115,45 @@ def drawPopupPointer(self, p): r = QRectF(self.rect()) if self.pointerPos == self.LeftSide: - PPIX_X = self.LR_MARGIN; PPIX_Y = PPIX_X*2.0 + PPIX_X = self.LR_MARGIN + PPIX_Y = PPIX_X * 2.0 points = [ - QPointF(QPoint(r.x()+PPIX_X, r.height()/2.0 - PPIX_Y/2.0)), - QPointF(QPoint(r.x()+PPIX_X, r.height()/2.0 + PPIX_Y/2.0)), - QPointF(QPoint(r.x(), r.height() / 2.0)) + QPointF(r.x() + PPIX_X, r.height() / 2.0 - PPIX_Y / 2.0), + QPointF(r.x() + PPIX_X, r.height() / 2.0 + PPIX_Y / 2.0), + QPointF(r.x(), r.height() / 2.0), ] p.drawPolygon(*points) if self.pointerPos == self.RightSide: - PPIX_X = self.LR_MARGIN; PPIX_Y = PPIX_X*2.0 + PPIX_X = self.LR_MARGIN + PPIX_Y = PPIX_X * 2.0 points = [ - QPointF(QPoint(r.right()-PPIX_X, r.height()/2.0 - PPIX_Y/2.0)), - QPointF(QPoint(r.right()-PPIX_X, r.height()/2.0 + PPIX_Y/2.0)), - QPointF(QPoint(r.right(), r.height() / 2.0)) + QPointF(r.right()-PPIX_X, r.height()/2.0 - PPIX_Y/2.0), + QPointF(r.right()-PPIX_X, r.height()/2.0 + PPIX_Y/2.0), + QPointF(r.right(), r.height() / 2.0), ] p.drawPolygon(*points) if self.pointerPos == self.TopSide: - PPIX_Y = self.TB_MARGIN; PPIX_X = PPIX_Y*2.0 + PPIX_Y = self.TB_MARGIN + PPIX_X = PPIX_Y*2.0 points = [ - QPointF(QPoint(r.x()+r.width()/2.0 - PPIX_X/2.0, r.top() + PPIX_Y)), - QPointF(QPoint(r.x()+r.width()/2.0 + PPIX_X/2.0, r.top() + PPIX_Y)), - QPointF(QPoint(r.x()+r.width()/2.0, r.top())) + QPointF(r.x()+r.width()/2.0 - PPIX_X/2.0, r.top() + PPIX_Y), + QPointF(r.x()+r.width()/2.0 + PPIX_X/2.0, r.top() + PPIX_Y), + QPointF(r.x()+r.width()/2.0, r.top()), ] p.drawPolygon(*points) if self.pointerPos == self.BottomSide: - PPIX_Y = self.TB_MARGIN; PPIX_X = PPIX_Y*2.0 + PPIX_Y = self.TB_MARGIN + PPIX_X = PPIX_Y*2.0 points = [ - QPointF(QPoint(r.x()+r.width()/2.0 - PPIX_X/2.0, r.bottom() - PPIX_Y)), - QPointF(QPoint(r.x()+r.width()/2.0 + PPIX_X/2.0, r.bottom() - PPIX_Y)), - QPointF(QPoint(r.x()+r.width()/2.0, r.bottom())) + QPointF(r.x()+r.width()/2.0 - PPIX_X/2.0, r.bottom() - PPIX_Y), + QPointF(r.x()+r.width()/2.0 + PPIX_X/2.0, r.bottom() - PPIX_Y), + QPointF(r.x()+r.width()/2.0, r.bottom()), ] p.drawPolygon(*points) diff --git a/electroncash_gui/qt/qrcodewidget.py b/electroncash_gui/qt/qrcodewidget.py index 7c5968430084..2bc1ae829683 100644 --- a/electroncash_gui/qt/qrcodewidget.py +++ b/electroncash_gui/qt/qrcodewidget.py @@ -58,8 +58,8 @@ def _bad_data(self, data): _black_brush = QBrush(QColor(0, 0, 0, 255)) _white_brush = QBrush(QColor(255, 255, 255, 255)) - _black_pen = QPen(_black_brush, 1.0, join = Qt.MiterJoin) - _white_pen = QPen(_white_brush, 1.0, join = Qt.MiterJoin) + _black_pen = QPen(_black_brush, 1.0, join=Qt.MiterJoin) + _white_pen = QPen(_white_brush, 1.0, join=Qt.MiterJoin) def paintEvent(self, e): matrix = None @@ -80,15 +80,15 @@ def paintEvent(self, e): margin = 5 framesize = min(r.width(), r.height()) - boxsize = int( (framesize - 2*margin)/k ) - size = k*boxsize - left = (r.width() - size)/2 - top = (r.height() - size)/2 + boxsize = (framesize - 2 * margin) // k + size = k * boxsize + left = (r.width() - size) // 2 + top = (r.height() - size) // 2 # Make a white margin around the QR in case of dark theme use qp.setBrush(self._white_brush) qp.setPen(self._white_pen) - qp.drawRect(left-margin, top-margin, size+(margin*2), size+(margin*2)) + qp.drawRect(left-margin, top-margin, size + (margin * 2), size + (margin * 2)) qp.setBrush(self._black_brush) qp.setPen(self._black_pen) diff --git a/electroncash_gui/qt/qrreader/camera_dialog.py b/electroncash_gui/qt/qrreader/camera_dialog.py index dd571fdbc5f9..69d533362ed3 100644 --- a/electroncash_gui/qt/qrreader/camera_dialog.py +++ b/electroncash_gui/qt/qrreader/camera_dialog.py @@ -212,8 +212,8 @@ def _get_crop(resolution: QSize, scan_size: int) -> QRect: """ Returns a QRect that is scan_size x scan_size in the middle of the resolution """ - scan_pos_x = (resolution.width() - scan_size) / 2 - scan_pos_y = (resolution.height() - scan_size) / 2 + scan_pos_x = int((resolution.width() - scan_size) / 2) + scan_pos_y = int((resolution.height() - scan_size) / 2) return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size) @staticmethod diff --git a/electroncash_gui/qt/qrreader/video_overlay.py b/electroncash_gui/qt/qrreader/video_overlay.py index d345d4b805fc..1e5b5437811c 100644 --- a/electroncash_gui/qt/qrreader/video_overlay.py +++ b/electroncash_gui/qt/qrreader/video_overlay.py @@ -66,7 +66,7 @@ def __init__(self, parent: QWidget = None): self.bg_rect_pen = QPen() self.bg_rect_pen.setColor(Qt.black) self.bg_rect_pen.setStyle(Qt.DotLine) - self.bg_rect_fill = QColor(255, 255, 255, 255 * self.BG_RECT_OPACITY) + self.bg_rect_fill = QColor(255, 255, 255, int(255 * self.BG_RECT_OPACITY)) self.qr_finder = QSvgRenderer(":icons/qr_finder.svg") @@ -99,12 +99,12 @@ def paintEvent(self, _event: QPaintEvent): # Compute the transform to flip the coordinate system on the x axis transform_flip = QTransform() if self.flip_x: - transform_flip = transform_flip.translate(self.resolution.width(), 0.0) + transform_flip = transform_flip.translate(float(self.resolution.width()), 0.0) transform_flip = transform_flip.scale(-1.0, 1.0) # Small helper for tuple to QPoint def toqp(point): - return QPoint(point[0], point[1]) + return QPoint(int(point[0]), int(point[1])) # Starting from here we care about AA painter.setRenderHint(QPainter.Antialiasing) diff --git a/electroncash_gui/qt/seed_dialog.py b/electroncash_gui/qt/seed_dialog.py index 2f77c07bf72d..63188f132348 100644 --- a/electroncash_gui/qt/seed_dialog.py +++ b/electroncash_gui/qt/seed_dialog.py @@ -181,7 +181,7 @@ def on_edit(self): status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist' label = 'BIP39' + ' (%s)'%status self.seed_type_label.setText(label) - self.parent.next_button.setEnabled(b) + self.parent.next_button.setEnabled(bool(b)) if may_clear_warning: self.seed_warning.setText('') @@ -200,7 +200,7 @@ def get_text(self): return self.text_e.text() def on_edit(self): - b = self.is_valid(self.get_text()) + b = bool(self.is_valid(self.get_text())) self.parent.next_button.setEnabled(b) diff --git a/electroncash_gui/qt/transaction_dialog.py b/electroncash_gui/qt/transaction_dialog.py index e86078889fc0..9b6b9b565800 100644 --- a/electroncash_gui/qt/transaction_dialog.py +++ b/electroncash_gui/qt/transaction_dialog.py @@ -268,8 +268,10 @@ def broadcast_done(success): # 5 second cooldown period on broadcast_button after successful # broadcast self.last_broadcast_time = time.time() - self.update() # disables the broadcast button if last_broadcast_time is < BROADCAST_COOLDOWN_SECS seconds ago - QTimer.singleShot(self.BROADCAST_COOLDOWN_SECS*1e3+100, self.update) # broadcast button will re-enable if we got nothing from server and >= BROADCAST_COOLDOWN_SECS elapsed + # disables the broadcast button if last_broadcast_time is < BROADCAST_COOLDOWN_SECS seconds ago + self.update() + # broadcast button will re-enable if we got nothing from server and >= BROADCAST_COOLDOWN_SECS elapsed + QTimer.singleShot(int(self.BROADCAST_COOLDOWN_SECS * 1e3 + 100), self.update) self.main_window.push_top_level_window(self) try: self.main_window.broadcast_transaction(self.tx, self.desc, callback=broadcast_done) @@ -427,7 +429,8 @@ def update(self): # Set the proper text (plural / singular form) self.freeze_button.setText(self._make_freeze_button_text(op, len(spends_coins_mine))) # Freeze/Unfreeze enabled only for signed transactions or transactions with frozen coins - self.freeze_button.setEnabled(has_frozen or status_enum in (StatusEnum.Signed, StatusEnum.PartiallySigned)) + self.freeze_button.setEnabled( + bool(has_frozen or status_enum in (StatusEnum.Signed, StatusEnum.PartiallySigned))) else: self.freeze_button.setEnabled(False) self.freeze_button.setText(self._make_freeze_button_text()) @@ -441,12 +444,11 @@ def update(self): # the "Broadcast" button for a time after a successful broadcast. # This prevents the user from being able to spam the broadcast # button. See #1483. - self.broadcast_button.setEnabled(can_broadcast - and time.time() - self.last_broadcast_time - >= self.BROADCAST_COOLDOWN_SECS) + self.broadcast_button.setEnabled( + bool(can_broadcast and time.time() - self.last_broadcast_time >= self.BROADCAST_COOLDOWN_SECS)) - can_sign = not self.tx.is_complete() and \ - (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs)) + can_sign = bool(not self.tx.is_complete() and + (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs))) self.sign_button.setEnabled(can_sign) self.tx_hash_e.setText(tx_hash or _('Unknown')) if fee is None: diff --git a/electroncash_gui/qt/util.py b/electroncash_gui/qt/util.py index 1e9da39ffac1..e4cc7efef2dc 100644 --- a/electroncash_gui/qt/util.py +++ b/electroncash_gui/qt/util.py @@ -5,6 +5,7 @@ import queue import threading import os +import weakref import webbrowser from collections import namedtuple from functools import partial, wraps @@ -27,12 +28,13 @@ dialogs = [] -from electroncash.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED +from electroncash.paymentrequest import PR_UNCONFIRMED, PR_UNPAID, PR_PAID, PR_EXPIRED pr_icons = { PR_UNPAID:":icons/unpaid.svg", PR_PAID:":icons/confirmed.svg", - PR_EXPIRED:":icons/expired.svg" + PR_EXPIRED:":icons/expired.svg", + PR_UNCONFIRMED: ":icons/unconfirmed.svg" } def _(message): return message @@ -522,16 +524,19 @@ def filename_field(config, defaultname, select_msg): gb.setLayout(vbox) b1 = QRadioButton() b1.setText(_("CSV")) - b1.setChecked(True) b2 = QRadioButton() b2.setText(_("JSON")) + if defaultname.endswith(".json"): + b2.setChecked(True) + else: + b1.setChecked(True) vbox.addWidget(b1) vbox.addWidget(b2) hbox = QHBoxLayout() directory = config.get('io_dir', os.path.expanduser('~')) - path = os.path.join( directory, defaultname ) + path = os.path.join(directory, defaultname) filename_e = QLineEdit() filename_e.setText(path) @@ -660,7 +665,7 @@ def editItem(self, item, column): item.setFlags(item.flags() & ~Qt.ItemIsEditable) def keyPressEvent(self, event): - if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None: + if event.key() in {Qt.Key_F2, Qt.Key_Return} and self.editor is None: item, col = self.currentItem(), self.currentColumn() if item and col > -1: self.on_activated(item, col) @@ -867,7 +872,7 @@ def _updateOverlayPos(self): if hasattr(self, 'verticalScrollBar') and self.verticalScrollBar().isVisible(): scrollbar_width = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) x -= scrollbar_width - self.overlay_widget.move(x, y) + self.overlay_widget.move(int(x), int(y)) def addWidget(self, widget: QWidget, index: int = None): if index is not None: @@ -1152,7 +1157,7 @@ def _invoke(self, args, kwargs): self.timer.timeout.connect(self._doIt) #self.timer.destroyed.connect(lambda x=None,qn=self.qn: print(qn,"Timer deallocated")) self.timer.setSingleShot(True) - self.timer.start(diff*1e3) + self.timer.start(int(diff*1e3)) #self.print_error("deferring") else: # We had a timer active, which means as future call will occur. So return early and let that call happenin the future. @@ -1332,6 +1337,7 @@ def webopen(url: str): else: webbrowser.open(url) + class TextBrowserKeyboardFocusFilter(QTextBrowser): """ This is a QTextBrowser that only enables keyboard text selection when the focus reason is @@ -1354,6 +1360,29 @@ def keyPressEvent(self, e: QKeyEvent): self.setTextInteractionFlags(self.textInteractionFlags() | Qt.TextSelectableByKeyboard) super().keyPressEvent(e) + +class OnDestroyedMixin: + """A mixin class designed to be used with any QObject. It will call the on_destroyed method (which can be + overridden), and it offers the property is_destroyed. Used in network_dialog.py. """ + def __init__(self): + assert isinstance(self, QObject) + self.is_destroyed = False + weak_self = weakref.ref(self) + + def handler(obj): + strong_self = weak_self() + if strong_self: + strong_self.on_destroyed(obj) + + self.destroyed.connect(lambda obj: handler(obj)) + + def on_destroyed(self, obj): + if self.is_destroyed: + return + self.is_destroyed = True + print_error(f"OnDestroyedMixin, object destroyed: {self!r}") + + if __name__ == "__main__": app = QApplication([]) t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done")) diff --git a/electroncash_gui/qt/utils/aspect_layout.py b/electroncash_gui/qt/utils/aspect_layout.py index c0c6d9d01372..9debe3c97815 100644 --- a/electroncash_gui/qt/utils/aspect_layout.py +++ b/electroncash_gui/qt/utils/aspect_layout.py @@ -70,8 +70,8 @@ def setGeometry(self, rect: QRect): c_aratio = 1 s_aratio = self.aspect_ratio item_rect = QRect(QPoint(0, 0), QSize( - contents.width() if c_aratio < s_aratio else contents.height() * s_aratio, - contents.height() if c_aratio > s_aratio else contents.width() / s_aratio + contents.width() if c_aratio < s_aratio else int(contents.height() * s_aratio), + contents.height() if c_aratio > s_aratio else int(contents.width() / s_aratio) )) content_margins = self.contentsMargins() @@ -82,7 +82,7 @@ def setGeometry(self, rect: QRect): if item.alignment() & Qt.AlignRight: item_rect.moveRight(contents.width() + content_margins.right()) else: - item_rect.moveLeft(content_margins.left() + (free_space.width() / 2)) + item_rect.moveLeft(content_margins.left() + (free_space.width() // 2)) else: item_rect.moveLeft(content_margins.left()) @@ -90,7 +90,7 @@ def setGeometry(self, rect: QRect): if item.alignment() & Qt.AlignBottom: item_rect.moveBottom(contents.height() + content_margins.bottom()) else: - item_rect.moveTop(content_margins.top() + (free_space.height() / 2)) + item_rect.moveTop(content_margins.top() + (free_space.height() // 2)) else: item_rect.moveTop(content_margins.top()) diff --git a/electroncash_gui/qt/utils/aspect_svg_widget.py b/electroncash_gui/qt/utils/aspect_svg_widget.py index 0042e3ee1718..22fbe0a87aaf 100644 --- a/electroncash_gui/qt/utils/aspect_svg_widget.py +++ b/electroncash_gui/qt/utils/aspect_svg_widget.py @@ -37,5 +37,5 @@ def __init__(self, width: int, file: str = None, parent: QObject = None): def sizeHint(self) -> QSize: svg_size = super().sizeHint() aspect_ratio = svg_size.width() / svg_size.height() - size_hint = QSize(self._width, self._width / aspect_ratio) + size_hint = QSize(int(self._width), int(self._width / aspect_ratio)) return size_hint diff --git a/electroncash_gui/qt/utils/color_utils.py b/electroncash_gui/qt/utils/color_utils.py index 212b46dfacf0..eae5206100f8 100644 --- a/electroncash_gui/qt/utils/color_utils.py +++ b/electroncash_gui/qt/utils/color_utils.py @@ -32,8 +32,8 @@ def QColorLerp(a: QColor, b: QColor, t: float): t = max(min(t, 1.0), 0.0) i_t = 1.0 - t return QColor( - (a.red() * i_t) + (b.red() * t), - (a.green() * i_t) + (b.green() * t), - (a.blue() * i_t) + (b.blue() * t), - (a.alpha() * i_t) + (b.alpha() * t), + int((a.red() * i_t) + (b.red() * t)), + int((a.green() * i_t) + (b.green() * t)), + int((a.blue() * i_t) + (b.blue() * t)), + int((a.alpha() * i_t) + (b.alpha() * t)), ) diff --git a/electroncash_gui/stdio.py b/electroncash_gui/stdio.py index 3e4bcaa47c2b..90b7bb48fd4c 100644 --- a/electroncash_gui/stdio.py +++ b/electroncash_gui/stdio.py @@ -46,7 +46,7 @@ def __init__(self, config, daemon, plugins): _("[r] - show own receipt addresses"), \ _("[c] - display contacts"), \ _("[b] - print server banner"), \ - _("[q] - quit") ] + _("[q] - quit")] self.num_commands = len(self.commands) def on_network(self, event, *args): @@ -101,7 +101,7 @@ def print_history(self): label = self.wallet.get_label(tx_hash) messages.append( format_str%( time_str, label, format_satoshis(delta, whitespaces=True), format_satoshis(balance, whitespaces=True) ) ) - self.print_list(messages[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance"))) + self.print_list(messages[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance"))) def print_balance(self): @@ -110,7 +110,7 @@ def print_balance(self): def get_balance(self): if self.wallet.network.is_connected(): if not self.wallet.up_to_date: - msg = _( "Synchronizing..." ) + msg = _("Synchronizing...") else: c, u, x = self.wallet.get_balance() msg = _("Balance")+": %f "%(PyDecimal(c) / COIN) @@ -119,7 +119,7 @@ def get_balance(self): if x: msg += " [%f unmatured]"%(PyDecimal(x) / COIN) else: - msg = _( "Not connected" ) + msg = _("Not connected") return(msg) @@ -148,8 +148,8 @@ def send_order(self): self.do_send() def print_banner(self): - for i, x in enumerate( self.wallet.network.banner.split('\n') ): - print( x ) + for i, x in enumerate(self.wallet.network.banner.split('\n')): + print(x) def print_list(self, lst, firstline): lst = list(lst) diff --git a/electroncash_gui/text.py b/electroncash_gui/text.py index 58c817b67d04..57b354f71980 100644 --- a/electroncash_gui/text.py +++ b/electroncash_gui/text.py @@ -79,7 +79,7 @@ def verify_seed(self): def get_string(self, y, x): self.set_cursor(1) curses.echo() - self.stdscr.addstr( y, x, " "*20, curses.A_REVERSE) + self.stdscr.addstr(y, x, " "*20, curses.A_REVERSE) s = self.stdscr.getstr(y,x) curses.noecho() self.set_cursor(0) @@ -100,7 +100,7 @@ def print_history(self): if self.history is None: self.update_history() - self.print_list(self.history[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance"))) + self.print_list(self.history[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance"))) def update_history(self): width = [20, 40, 14, 14] @@ -141,10 +141,10 @@ def print_balance(self): else: msg = _("Not connected") - self.stdscr.addstr( self.maxy -1, 3, msg) + self.stdscr.addstr(self.maxy -1, 3, msg) for i in range(self.num_tabs): - self.stdscr.addstr( 0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0) + self.stdscr.addstr(0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0) self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")])) @@ -165,9 +165,9 @@ def print_addresses(self): self.print_list(messages, fmt % ("Address", "Label")) def print_edit_line(self, y, label, text, index, size): - text += " "*(size - len(text) ) - self.stdscr.addstr( y, 2, label) - self.stdscr.addstr( y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1)) + text += " "*(size - len(text)) + self.stdscr.addstr(y, 2, label) + self.stdscr.addstr(y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1)) def print_send_tab(self): self.stdscr.clear() @@ -175,8 +175,8 @@ def print_send_tab(self): self.print_edit_line(5, _("Description"), self.str_description, 1, 40) self.print_edit_line(7, _("Amount"), self.str_amount, 2, 15) self.print_edit_line(9, _("Fee"), self.str_fee, 3, 15) - self.stdscr.addstr( 12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2)) - self.stdscr.addstr( 12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2)) + self.stdscr.addstr(12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2)) + self.stdscr.addstr(12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2)) self.maxpos = 6 def print_banner(self): @@ -206,13 +206,13 @@ def print_list(self, lst, firstline = None): if not self.maxpos: return if firstline: firstline += " "*(self.maxx -2 - len(firstline)) - self.stdscr.addstr( 1, 1, firstline ) + self.stdscr.addstr(1, 1, firstline) for i in range(self.maxy-4): msg = lst[i] if i < len(lst) else "" msg += " "*(self.maxx - 2 - len(msg)) m = msg[0:self.maxx - 2] m = m.encode(self.encoding) - self.stdscr.addstr( i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0) + self.stdscr.addstr(i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0) def refresh(self): if self.tab == -1: return @@ -401,7 +401,7 @@ def network_dialog(self): def settings_dialog(self): fee = str(PyDecimal(self.config.fee_per_kb()) / COIN) out = self.run_dialog('Settings', [ - {'label':'Default fee', 'type':'satoshis', 'value': fee } + {'label':'Default fee', 'type':'satoshis', 'value': fee} ], buttons = 1) if out: if out.get('Default fee'): @@ -419,13 +419,13 @@ def password_dialog(self): def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3): self.popup_pos = 0 - self.w = curses.newwin( 5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5) + self.w = curses.newwin(5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5) w = self.w out = {} while True: w.clear() w.border(0) - w.addstr( 0, 2, title) + w.addstr(0, 2, title) num = len(list(items)) @@ -451,14 +451,14 @@ def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3): value += ' '*(20-len(value)) if 'value' in item: - w.addstr( 2+interval*i, 2, label) - w.addstr( 2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1) ) + w.addstr(2+interval*i, 2, label) + w.addstr(2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1)) else: - w.addstr( 2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0) + w.addstr(2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0) if buttons: - w.addstr( 5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2)) - w.addstr( 5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2)) + w.addstr(5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2)) + w.addstr(5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2)) w.refresh() diff --git a/electroncash_plugins/fusion/conf.py b/electroncash_plugins/fusion/conf.py index d3804be9b59c..ba1bac53aac4 100644 --- a/electroncash_plugins/fusion/conf.py +++ b/electroncash_plugins/fusion/conf.py @@ -46,8 +46,10 @@ class Defaults: CoinbaseSeenLatch = False FusionMode = 'normal' QueudAutofuse = 4 + FuseDepth = 0 # Fuse forever by default Selector = ('fraction', 0.1) # coin selector options SelfFusePlayers = 1 # self-fusing control (1 = just self, more than 1 = self fuse up to N times) + SpendOnlyFusedCoins = False # spendable_coin_filter @hook def __init__(self, wallet): @@ -113,6 +115,16 @@ def queued_autofuse(self, i : Optional[int]): i = int(i) self.wallet.storage.put('cashfusion_queued_autofuse', i) + @property + def fuse_depth(self) -> int: + return int(self.wallet.storage.get('cashfusion_fuse_depth', self.Defaults.FuseDepth)) + @fuse_depth.setter + def fuse_depth(self, i : Optional[int]): + if i is not None: + assert i >= 0 + i = int(i) + self.wallet.storage.put('cashfusion_fuse_depth', i) + @property def selector(self) -> Tuple[str, Union[int,float]]: return tuple(self.wallet.storage.get('cashfusion_selector', self.Defaults.Selector)) @@ -132,6 +144,13 @@ def self_fuse_players(self, i : Optional[int]): i = int(i) return self.wallet.storage.put('cashfusion_self_fuse_players', i) + @property + def spend_only_fused_coins(self) -> bool: + return bool(self.wallet.storage.get('cashfusion_spend_only_fused_coins', self.Defaults.SpendOnlyFusedCoins)) + @spend_only_fused_coins.setter + def spend_only_fused_coins(self, b: bool): + return self.wallet.storage.put('cashfusion_spend_only_fused_coins', bool(b)) + CashFusionServer = namedtuple("CashFusionServer", ('hostname', 'port', 'ssl')) diff --git a/electroncash_plugins/fusion/fusion.py b/electroncash_plugins/fusion/fusion.py index 0d3449f40f1d..3fd2128160c6 100644 --- a/electroncash_plugins/fusion/fusion.py +++ b/electroncash_plugins/fusion/fusion.py @@ -86,13 +86,12 @@ def can_fuse_from(wallet): """We can only fuse from wallets that are p2pkh, and where we are able to extract the private key.""" - return (not (wallet.is_watching_only() or wallet.is_hardware() or isinstance(wallet, Multisig_Wallet)) - and networks.net is not networks.TaxCoinNet) + return not (wallet.is_watching_only() or wallet.is_hardware() or isinstance(wallet, Multisig_Wallet)) def can_fuse_to(wallet): """We can only fuse to wallets that are p2pkh with HD generation. We do *not* need the private keys.""" - return isinstance(wallet, Standard_Wallet) and networks.net is not networks.TaxCoinNet + return isinstance(wallet, Standard_Wallet) @@ -601,9 +600,9 @@ def allocate_outputs(self,): # linkage somehow, which means throwing away some sats as extra fees beyond # the minimum requirement. - # For now, just throw on a few unobtrusive extra sats at the higher tiers, at most 9. - # TODO: smarter selection for high tiers (how much will users be comfortable paying?) - fuzz_fee_max = min(9, scale // 1000000) + # Just use (tier / 10^6) as fuzzing range. For a 10 BCH tier this means + # randomly overpaying fees of 0 to 1000 sats. + fuzz_fee_max = scale // 1000000 ### End fuzzing fee range selection ### @@ -617,9 +616,6 @@ def allocate_outputs(self,): fuzz_fee = secrets.randbelow(fuzz_fee_max_reduced + 1) assert fuzz_fee <= fuzz_fee_max_reduced and fuzz_fee_max_reduced <= fuzz_fee_max - # TODO: this can be removed when the above is updated - assert fuzz_fee < 100, 'sanity check: example fuzz fee should be small' - reduced_avail_for_outputs = avail_for_outputs - fuzz_fee if reduced_avail_for_outputs < offset_per_output: continue diff --git a/electroncash_plugins/fusion/plugin.py b/electroncash_plugins/fusion/plugin.py index a720dc0aad11..d46926e52e96 100644 --- a/electroncash_plugins/fusion/plugin.py +++ b/electroncash_plugins/fusion/plugin.py @@ -34,17 +34,19 @@ from typing import Optional, Tuple -from electroncash.address import Address -from electroncash.bitcoin import COINBASE_MATURITY +from electroncash.address import Address, OpCodes +from electroncash.bitcoin import COINBASE_MATURITY, TYPE_SCRIPT from electroncash.plugins import BasePlugin, hook, daemon_command from electroncash.i18n import _, ngettext, pgettext from electroncash.util import profiler, PrintError, InvalidPassword -from electroncash import Network, networks +from electroncash import Network, networks, Transaction from .conf import Conf, Global from .fusion import Fusion, can_fuse_from, can_fuse_to, is_tor_port, MIN_TX_COMPONENTS from .server import FusionServer from .covert import limiter +from .protocol import Protocol +from .util import get_coin_name import random # only used to select random coins @@ -71,6 +73,12 @@ CONSOLIDATE_MAX_OUTPUTS = MIN_TX_COMPONENTS // 3 +# Threshold for the amount (sats) for a wallet to be fully fused. This is to avoid refuse when dusted. +FUSE_DEPTH_THRESHOLD = 0.95 + +# We don't allow a fuse depth beyond this in the wallet UI +MAX_LIMIT_FUSE_DEPTH = 10 + pnp = None def get_upnp(): """ return an initialized UPnP singleton """ @@ -293,6 +301,7 @@ def __init__(self, *args, **kwargs): self.fusions = weakref.WeakKeyDictionary() self.autofusing_wallets = weakref.WeakKeyDictionary() # wallet -> password + self.registered_network_callback = False self.t_last_net_ok = time.monotonic() @@ -310,6 +319,11 @@ def __init__(self, *args, **kwargs): def on_close(self,): super().on_close() self.stop_fusion_server() + if self.registered_network_callback: + self.registered_network_callback = False + network = Network.get_instance() + if network: + network.unregister_callback(self.on_wallet_transaction) self.active = False def fullname(self): @@ -319,7 +333,7 @@ def description(self): return _("CashFusion Protocol") def is_available(self): - return networks.net is not networks.TaxCoinNet + return True def set_remote_donation_address(self, address : str): self.remote_donation_address = ((isinstance(address, str) and address) or '')[:100] @@ -466,6 +480,10 @@ def add_wallet(self, wallet, password=None): wallet._fusions = weakref.WeakSet() # fusions that were auto-started. wallet._fusions_auto = weakref.WeakSet() + # caache: stores a map of txid -> fusion_depth (or False if txid is not a fuz tx) + wallet._cashfusion_is_fuz_txid_cache = dict() + # cache: stores a map of address -> fusion_depth if the address has fuz utxos + wallet._cashfusion_address_cache = dict() # all accesses to the above must be protected by wallet.lock if Conf(wallet).autofuse: @@ -473,6 +491,9 @@ def add_wallet(self, wallet, password=None): self.enable_autofusing(wallet, password) except InvalidPassword: self.disable_autofusing(wallet) + if not self.registered_network_callback and wallet.network: + wallet.network.register_callback(self.on_wallet_transaction, ['new_transaction']) + self.registered_network_callback = True def remove_wallet(self, wallet): ''' Detach the provided wallet; returns list of active fusion threads. ''' @@ -484,6 +505,8 @@ def remove_wallet(self, wallet): fusions = list(wallet._fusions) del wallet._fusions del wallet._fusions_auto + del wallet._cashfusion_is_fuz_txid_cache + del wallet._cashfusion_address_cache except AttributeError: pass return [f for f in fusions if f.is_alive()] @@ -596,6 +619,19 @@ def run(self, ): for f in list(wallet._fusions_auto): f.stop('Wallet has unconfirmed coins... waiting.', not_if_running = True) continue + + fuse_depth = Conf(wallet).fuse_depth + if fuse_depth > 0: + sum_eligible_values = 0 + sum_fuz_values = 0 + for eaddr, ecoins in eligible: + ecoins_value = sum(ecoin['value'] for ecoin in ecoins) + sum_eligible_values += ecoins_value + if self.is_fuz_address(wallet, eaddr, require_depth=fuse_depth-1): + sum_fuz_values += ecoins_value + if sum_eligible_values != 0 and sum_fuz_values / sum_eligible_values >= FUSE_DEPTH_THRESHOLD: + continue + if not dont_start_fusions and num_auto < min(target_num_auto, MAX_AUTOFUSIONS_PER_WALLET): # we don't have enough auto-fusions running, so start one fraction = get_target_params_2(wallet_conf, sum_value) @@ -653,6 +689,162 @@ def donation_address(self, window) -> Optional[Tuple[str,Address]]: if self.remote_donation_address and Address.is_valid(self.remote_donation_address): return (self.fullname() + " " + _("Server") + ": " + self.get_server()[0], Address.from_string(self.remote_donation_address)) + @staticmethod + def wallet_can_fuse(wallet) -> bool: + return can_fuse_from(wallet) and can_fuse_to(wallet) + + @staticmethod + def is_fuz_coin(wallet, coin, *, require_depth=0) -> Optional[bool]: + """ Returns True if the coin in question is definitely a CashFusion coin (uses heuristic matching), + or False if the coin in question is not from a CashFusion tx. Returns None if the tx for the coin + is not (yet) known to the wallet (None == inconclusive answer, caller may wish to try again later). + If require_depth is > 0, check recursively; will return True if all ancestors of the coin + up to require_depth are also CashFusion transactions belonging to this wallet. + + Precondition: wallet must be a fusion wallet. """ + + require_depth = min(max(0, require_depth), 900) # paranoia: clamp to [0, 900] + + cache = wallet._cashfusion_is_fuz_txid_cache + assert isinstance(cache, dict) + txid = coin['prevout_hash'] + # check cache, if cache hit, return answer and avoid the lookup below + cached_val = cache.get(txid, None) + if cached_val is not None: + # cache stores either False, or a depth for which the predicate is true + if cached_val is False: + return False + elif cached_val >= require_depth: + return True + + my_addresses_seen = set() + + def check_is_fuz_tx(): + tx = wallet.transactions.get(txid, None) + if tx is None: + # Not found in wallet.transactions so its fuz status is as yet "unknown". Indicate this. + return None + inputs = tx.inputs() + outputs = tx.outputs() + # We expect: OP_RETURN (4) FUZ\x00 + fuz_prefix = bytes((OpCodes.OP_RETURN, len(Protocol.FUSE_ID))) + Protocol.FUSE_ID + # Step 1 - does it have the proper OP_RETURN lokad prefix? + for typ, dest, amt in outputs: + if amt == 0 and typ == TYPE_SCRIPT and dest.script.startswith(fuz_prefix): + break # lokad found, proceed to Step 2 below + else: + # Nope, lokad prefix not found + return False + # Step 2 - are at least 1 of the inputs from me? (DoS prevention measure) + for inp in inputs: + inp_addr = inp.get('address', None) + if inp_addr is not None and (inp_addr in my_addresses_seen or wallet.is_mine(inp_addr)): + my_addresses_seen.add(inp_addr) + if require_depth == 0: + return True # This transaction is a CashFusion tx + # [Optional] Step 3 - Check if all ancestors up to required_depth are also fusions + if not FusionPlugin.is_fuz_coin(wallet, inp, require_depth=require_depth-1): + # require_depth specified and not all required_depth parents were CashFusion + return False + if my_addresses_seen: + # require_depth > 0: This tx + all wallet ancestors were CashFusion transactions up to require_depth + return True + # Failure -- this tx has the lokad but no inputs are "from me". + wallet.print_error(f"CashFusion: txid \"{txid}\" has a CashFusion-style OP_RETURN but none of the " + f"inputs are from this wallet. This is UNEXPECTED!") + return False + # /check_is_fuz_tx + + answer = check_is_fuz_tx() + if isinstance(answer, bool): + # maybe cache the answer if it's a definitive answer True/False + if require_depth == 0: + # we got an answer for this coin's tx itself + if not answer: + cache[txid] = False + elif not cached_val: + # only set the cached val if it was missing previously, to avoid overwriting higher values + cache[txid] = 0 + elif answer and (cached_val is None or cached_val < require_depth): + # indicate true up to the depth we just checked + cache[txid] = require_depth + elif not answer and isinstance(cached_val, int) and cached_val >= require_depth: + # this should never happen + wallet.print_error(f"CashFusion: WARNING txid \"{txid}\" has inconsistent state in " + f"the _cashfusion_is_fuz_txid_cache") + if answer: + # remember this address as being a "fuzed" address and cache the positive reply + cache2 = wallet._cashfusion_address_cache + assert isinstance(cache2, dict) + addr = coin.get('address', None) + if addr: + my_addresses_seen.add(addr) + for addr in my_addresses_seen: + depth = cache2.get(addr, None) + if depth is None or depth < require_depth: + cache2[addr] = require_depth + return answer + + @classmethod + def get_coin_fuz_count(cls, wallet, coin, *, require_depth=0): + """ Will return a fuz count for a coin. Unfused or unknown coins have count 0, coins + that appear in a fuz tx have count 1, coins whose wallet parent txs are all fuz are 2, 3, etc + depending on how far back the fuz perdicate is satisfied. + + This function only checks up to 10 ancestors deep so tha maximum return value is 10. + + Precondition: wallet must be a fusion wallet. """ + + require_depth = min(max(require_depth, 0), MAX_LIMIT_FUSE_DEPTH - 1) + cached_ct = wallet._cashfusion_is_fuz_txid_cache.get(coin['prevout_hash']) + if isinstance(cached_ct, int) and cached_ct >= require_depth: + return cached_ct + 1 + ret = 0 + for i in range(cached_ct or 0, require_depth + 1, 1): + ret = i + if not cls.is_fuz_coin(wallet, coin, require_depth=i): + break + return ret + + @classmethod + def is_fuz_address(cls, wallet, address, *, require_depth=0): + """ Returns True if address contains any fused UTXOs. + Optionally, specify require_depth, in which case True is returned + if any UTXOs for this address are sufficiently fused to the + specified depth. + + If you want thread safety, caller must hold wallet locks. + + Precondition: wallet must be a fusion wallet. """ + + assert isinstance(address, Address) + require_depth = max(require_depth, 0) + + cache = wallet._cashfusion_address_cache + assert isinstance(cache, dict) + cached_val = cache.get(address, None) + if cached_val is not None and cached_val >= require_depth: + return True + + utxos = wallet.get_addr_utxo(address) + for coin in utxos.values(): + if cls.is_fuz_coin(wallet, coin, require_depth=require_depth): + if cached_val is None or cached_val < require_depth: + cache[address] = require_depth + return True + return False + + @staticmethod + def on_wallet_transaction(event, *args): + """ Network object callback. Always called in the Network object's thread. """ + if event == 'new_transaction': + # if this is a fusion wallet, clear the is_fuz_address() cache when new transactions arrive + # since we may have spent some utxos and so the cache needs to be invalidated + wallet = args[1] + if hasattr(wallet, '_cashfusion_address_cache'): + with wallet.lock: + wallet._cashfusion_address_cache.clear() + @daemon_command def fusion_server_start(self, daemon, config): # Usage: diff --git a/electroncash_plugins/fusion/qt.py b/electroncash_plugins/fusion/qt.py index d8858a27a058..a76062d04dfd 100644 --- a/electroncash_plugins/fusion/qt.py +++ b/electroncash_plugins/fusion/qt.py @@ -28,12 +28,14 @@ import weakref from functools import partial +from typing import Optional from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * from electroncash import networks +from electroncash.address import Address from electroncash.i18n import _, ngettext, pgettext from electroncash.plugins import hook, run_hook from electroncash.util import ( @@ -50,7 +52,8 @@ from .conf import Conf, Global from .fusion import can_fuse_from, can_fuse_to from .server import Params -from .plugin import FusionPlugin, TOR_PORTS, COIN_FRACTION_FUDGE_FACTOR, select_coins +from .plugin import FusionPlugin, TOR_PORTS, COIN_FRACTION_FUDGE_FACTOR, select_coins, MAX_LIMIT_FUSE_DEPTH +from .util import get_coin_name from pathlib import Path heredir = Path(__file__).parent @@ -157,24 +160,42 @@ def do_it(password): do_it(password) if coins: - menu.addAction(ngettext("Input one coin to CashFusion", "Input {count} coins to CashFusion", len(coins)).format(count = len(coins)), + menu.addAction(ngettext("Input one coin to CashFusion", + "Input {count} coins to CashFusion", + len(coins)).format(count=len(coins)), start_fusion) + @staticmethod + def get_spend_only_fused_coins_checkbox_attributes(wallet): + fuse_depth = Conf(wallet).fuse_depth + if fuse_depth > 0: + label = ngettext("Spend only fused coins, minimum {min} fusion", + "Spend only fused coins, minimum {min} fusions", + fuse_depth).format(min=fuse_depth) + tooltip = ngettext("If checked, only spend coins that have been anonymized by\n" + "CashFusion, after having been fused at least {min} time.", + "If checked, only spend coins that have been anonymized by\n" + "CashFusion, after having been fused at least {min} times.", + fuse_depth).format(min=fuse_depth) + else: + label = _("Spend only fused coins") + tooltip = _("If checked, only spend coins that have been\n" + "anonymized by CashFusion at least once.") + return label, tooltip + @hook def on_new_window(self, window): # Called on initial plugin load (if enabled) and every new window; only once per window. wallet = window.wallet - can_fuse = can_fuse_from(wallet) and can_fuse_to(wallet) + can_fuse = self.wallet_can_fuse(wallet) if can_fuse: sbbtn = FusionButton(self, wallet) self.server_status_changed_signal.connect(sbbtn.update_server_error) - elif networks.net is networks.TaxCoinNet: - sbmsg = _('CashFusion is not available on ABC TaxCoin') - sbbtn = DisabledFusionButton(wallet, sbmsg) else: # If we can not fuse we create a dummy fusion button that just displays a message - sbmsg = _('This wallet type ({wtype}) cannot be used with CashFusion.\n\nPlease use a standard deterministic spending wallet with CashFusion.').format(wtype=wallet.wallet_type) + sbmsg = _('This wallet type ({wtype}) cannot be used with CashFusion.\n\n' + 'Please use a standard deterministic spending wallet with CashFusion.').format(wtype=wallet.wallet_type) sbbtn = DisabledFusionButton(wallet, sbmsg) # bit of a dirty hack, to insert our status bar icon (always using index 4, should put us just after the password-changer icon) @@ -188,10 +209,32 @@ def on_new_window(self, window): # (if inter-wallet fusing is added, this should change.) return + # NEW! Set up the send tab "Spend only fused coins" checkbox/control + if hasattr(window, 'send_tab_extra_plugin_controls_hbox'): + hbox = window.send_tab_extra_plugin_controls_hbox + label, tooltip = self.get_spend_only_fused_coins_checkbox_attributes(wallet) + spend_only_fused_chk = QCheckBox(label) + spend_only_fused_chk.setObjectName('spend_only_fused_chk') + spend_only_fused_chk.setToolTip(tooltip) + hbox.insertWidget(0, spend_only_fused_chk) + spend_only_fused_chk.setChecked(Conf(wallet).spend_only_fused_coins) + weak_window = weakref.ref(window) + def on_chk(b): + window = weak_window() + if window: + wallet = window.wallet + Conf(wallet).spend_only_fused_coins = b + window.do_update_fee() # trigger send tab to re-calculate things + spend_only_fused_chk.toggled.connect(on_chk) + self.widgets.add(spend_only_fused_chk) + want_autofuse = Conf(wallet).autofuse self.add_wallet(wallet, window.gui_object.get_cached_password(wallet)) sbbtn.update_state() + # Set up the utxo_list column + self.patch_utxo_list(window.utxo_list) + # prompt for password if auto-fuse was enabled if want_autofuse and not self.is_autofusing(wallet): def callback(password): @@ -203,8 +246,160 @@ def callback(password): d.show() self.widgets.add(d) + @staticmethod + def patch_utxo_list(utxo_list): + if getattr(utxo_list, '_fusion_patched_', None) is not None: + return + header = utxo_list.headerItem() + header_labels = [header.text(i) for i in range(header.columnCount())] + header_labels.append(_("Fusion Status")) + utxo_list.update_headers(header_labels) + utxo_list._fusion_patched_ = header_labels[-1] # save the text to be able to find the column later + utxo_list.wallet.print_error("[fusion] Patched utxo_list") + + @staticmethod + def find_utxo_list_fusion_column(utxo_list, *, just_column=False): + label_text = getattr(utxo_list, '_fusion_patched_', None) + if label_text is None: + return + header = utxo_list.headerItem() + if just_column: + # fast path, query just the column + col_ct = header.columnCount() + if not col_ct: + return None + # iterate in reverse (it's likely last) + for i in range(col_ct - 1, -1, -1): + if header.text(i) == label_text: + return i + else: + header_labels = [header.text(i) for i in range(header.columnCount())] + col = len(header_labels) - 1 + # find the column, iterate in reverse since it's likely last + for i, lbl in enumerate(reversed(header_labels)): + if lbl == label_text: + col = len(header_labels) - 1 - i + break + return col, header, header_labels + + @staticmethod + def unpatch_utxo_list(utxo_list): + tup = Plugin.find_utxo_list_fusion_column(utxo_list) + if tup is None: + return + col, header, header_labels = tup + del header_labels[col] + utxo_list.update_headers(header_labels) + delattr(utxo_list, '_fusion_patched_') + utxo_list.wallet.print_error("[fusion] Unpatched utxo_list") + + @hook + def utxo_list_item_setup(self, utxo_list, item, utxo, name): + col = self.find_utxo_list_fusion_column(utxo_list, just_column=True) + if col is None: + return + + wallet = utxo_list.wallet + fuse_depth = Conf(wallet).fuse_depth + frozenstring = item.data(0, utxo_list.DataRoles.frozen_flags) or "" + is_slp = 's' in frozenstring + is_fused = self.is_fuz_coin(wallet, utxo, require_depth=fuse_depth-1) + is_partially_fused = is_fused if fuse_depth <= 1 else self.is_fuz_coin(wallet, utxo) + + item.setIcon(col, QIcon()) + if is_slp: + item.setText(col, _("SLP Token")) + elif is_fused: + item.setText(col, _("Fused")) + item.setIcon(col, icon_fusion_logo) + elif is_partially_fused: + count = self.get_coin_fuz_count(wallet, utxo, require_depth=fuse_depth-1) + item.setText(col, _("Partial {count}/{total}").format(count=count, total=fuse_depth)) + item.setIcon(col, icon_fusion_logo_gray) + elif self.is_fuz_address(wallet, utxo['address'], require_depth=fuse_depth-1): + item.setText(col, _("Fuse Addr")) + item.setIcon(col, icon_fusion_logo) + item.setToolTip(col, _("This coin shares an address with a fused coin. Do not spend separately.")) + elif utxo['height'] <= 0: + item.setText(col, _("Unconfirmed")) + elif utxo['coinbase']: + # we disallow coinbase coins unconditionally -- due to miner feedback (they don't like shuffling these) + item.setText(col, _("Coinbase")) + else: + item.setText(col, _("Unfused")) + + @hook + def spendable_coin_filter(self, window, coins): + """ Invoked by the send tab to filter out coins that aren't fused if the wallet has + 'spend only fused coins' enabled. """ + if not coins or not hasattr(window, 'wallet'): + return + + wallet = window.wallet + if not Conf(wallet).spend_only_fused_coins or not self.wallet_can_fuse(wallet): + return + + # external_coins_addresses is only ever used if they are doing a sweep. in which case we always allow the coins + # involved in the sweep + external_coin_addresses = set() + if hasattr(window, 'tx_external_keypairs'): + for pubkey in window.tx_external_keypairs: + a = Address.from_pubkey(pubkey) + external_coin_addresses.add(a) + + # we can ONLY spend fused coins + ununfused living on a fused coin address + fuz_adrs_seen = set() + fuz_coins_seen = set() + with wallet.lock: + for coin in coins.copy(): + if coin['address'] in external_coin_addresses: + # completely bypass this filter for external keypair dict + # which is only used for sweep dialog in send tab + continue + fuse_depth = Conf(wallet).fuse_depth + is_fuz_adr = self.is_fuz_address(wallet, coin['address'], require_depth=fuse_depth-1) + if is_fuz_adr: + fuz_adrs_seen.add(coin['address']) + # we allow coins sitting on a fused address to be "spent as fused" + if not self.is_fuz_coin(wallet, coin, require_depth=fuse_depth-1) and not is_fuz_adr: + coins.remove(coin) + else: + fuz_coins_seen.add(get_coin_name(coin)) + # Force co-spending of other coins sitting on a fuzed address + for adr in fuz_adrs_seen: + adr_coins = wallet.get_addr_utxo(adr) + for name, adr_coin in adr_coins.items(): + if (name not in fuz_coins_seen + and not adr_coin['is_frozen_coin'] + and adr_coin.get('slp_token') is None + and not adr_coin.get('coinbase')): + coins.append(adr_coin) + fuz_coins_seen.add(name) + + @hook + def not_enough_funds_extra(self, window) -> Optional[str]: + """ Called by the Qt UI if there is a "not enough funds" error in the send tab """ + wallet = window.wallet + if not self.wallet_can_fuse(wallet): + return + conf = Conf(wallet) + if not conf.spend_only_fused_coins: + return + needs_fuz = [coin for coin in wallet.get_utxos(exclude_frozen=True, mature=True, + confirmed_only=bool(window.config.get('confirmed_only', False))) + if not self.is_fuz_coin(wallet, coin, require_depth=conf.fuse_depth-1)] + total = sum(c['value'] for c in needs_fuz) + n_coins = len(needs_fuz) + if total and needs_fuz: + return ngettext("{total_bch} in {n_coins} unfused coin", + "{total_bch} in {n_coins} unfused coins", n_coins).format( + total_bch=window.format_amount(total) + ' ' + window.base_unit(), + n_coins=n_coins + ) + @hook def on_close_window(self, window): + self.unpatch_utxo_list(window.utxo_list) # Invoked when closing wallet or entire application # Also called by on_close, above. wallet = window.wallet @@ -928,6 +1123,8 @@ def scan_torport_loop(self, ): class WalletSettingsDialog(WindowModalDialog): + GUI_DEFAULT_FUSE_DEPTH = 3 # This what the fuse depth spinbox defaults to, if checked (on new installs) + def __init__(self, parent, plugin, wallet): super().__init__(parent=parent, title=_("CashFusion - Wallet Settings")) self.setWindowIcon(icon_fusion_logo) @@ -953,15 +1150,32 @@ def __init__(self, parent, plugin, wallet): main_layout.addLayout(hbox) + self.gb_fuse_depth = gb = QGroupBox(_("Fusion Rounds")) + gb.setToolTip(_("If checked, CashFusion will fuse each coin this many times.\n" + "If unchecked, Cashfusion will fuse indefinitely until paused.")) + hbox = QHBoxLayout(gb) + self.chk_fuse_depth = chk = QCheckBox(_("Fuse coins this many times")) + hbox.addWidget(chk, 1) + self.sb_fuse_depth = sb = QSpinBox() + sb.setRange(1, MAX_LIMIT_FUSE_DEPTH) + if self.conf.fuse_depth <= 0: + # Default it to this if unchecked + self.sb_fuse_depth.setValue(self.GUI_DEFAULT_FUSE_DEPTH) + sb.setMinimumWidth(75) + hbox.addWidget(sb) + chk.toggled.connect(self.edited_fuse_depth) + sb.valueChanged.connect(self.edited_fuse_depth) + main_layout.addWidget(gb) + self.gb_coinbase = gb = QGroupBox(_("Coinbase Coins")) vbox = QVBoxLayout(gb) self.cb_coinbase = QCheckBox(_('Auto-fuse coinbase coins (if mature)')) self.cb_coinbase.clicked.connect(self._on_cb_coinbase) vbox.addWidget(self.cb_coinbase) - # The coinbase-related group box is hidden by default. It becomes - # visible permanently when the wallet settings dialog has seen at least - # one coinbase coin, indicating a miner's wallet. For most users the - # coinbase checkbox is confusing, which is why we prefer to hide it. + # The coinbase-related group box is hidden by default. It becomes + # visible permanently when the wallet settings dialog has seen at least + # one coinbase coin, indicating a miner's wallet. For most users the + # coinbase checkbox is confusing, which is why we prefer to hide it. gb.setHidden(True) main_layout.addWidget(gb) @@ -1160,7 +1374,7 @@ def refresh(self): edit_widgets = [self.amt_selector_size, self.sb_selector_fraction, self.sb_selector_count, self.sb_queued_autofuse, self.cb_autofuse_only_all_confirmed, self.combo_self_fuse, self.stacked_layout, self.mode_cb, - self.cb_coinbase] + self.cb_coinbase, self.sb_fuse_depth, self.chk_fuse_depth] try: for w in edit_widgets: # Block spurious editingFinished signals and valueChanged signals as @@ -1188,6 +1402,11 @@ def refresh(self): self.combo_self_fuse.setCurrentIndex(idx) del idx + if self.conf.fuse_depth > 0: + self.sb_fuse_depth.setValue(self.conf.fuse_depth) + self.chk_fuse_depth.setChecked(self.conf.fuse_depth > 0) + self.sb_fuse_depth.setEnabled(self.conf.fuse_depth > 0) + if is_custom_page: self.amt_selector_size.setEnabled(select_type == 'size') self.sb_selector_count.setEnabled(select_type == 'count') @@ -1210,7 +1429,7 @@ def refresh(self): else: self.conf.selector = None return self.refresh() - sel_count = COIN_FRACTION_FUDGE_FACTOR / max(sel_fraction, 0.001) + sel_count = round(COIN_FRACTION_FUDGE_FACTOR / max(sel_fraction, 0.001)) self.amt_selector_size.setAmount(round(sel_size)) self.sb_selector_fraction.setValue(max(min(sel_fraction, 1.0), 0.001) * 100.0) self.sb_selector_count.setValue(sel_count) @@ -1250,6 +1469,26 @@ def edited_queued_autofuse(self,): f.stop('User decreased queued-fuse limit', not_if_running = True) self.refresh() + def edited_fuse_depth(self,): + prevval = self.conf.fuse_depth + newval = self.sb_fuse_depth.value() if self.chk_fuse_depth.isChecked() else 0 + self.conf.fuse_depth = newval + if prevval == 0 or (prevval > newval and newval != 0): + for f in list(self.wallet._fusions_auto): + f.stop('User decreased fuse depth limit', not_if_running = False) + # update the send tab label for the "spend only confirmed coins" checkbox + main_window = self.wallet.weak_window and self.wallet.weak_window() + if main_window: + chk = main_window.findChild(QCheckBox, 'spend_only_fused_chk', Qt.FindChildrenRecursively) + if chk: + label, tooltip = self.plugin.get_spend_only_fused_coins_checkbox_attributes(self.wallet) + chk.setText(label) + chk.setToolTip(tooltip) + # Coins tab may need redisplay if we changed these settings + if prevval != newval: + main_window.utxo_list.update() + self.refresh() + def clicked_confirmed_only(self, checked): self.conf.autofuse_confirmed_only = checked self.refresh() diff --git a/electroncash_plugins/fusion/server.py b/electroncash_plugins/fusion/server.py index e72a2f42f6ca..1873ef386045 100644 --- a/electroncash_plugins/fusion/server.py +++ b/electroncash_plugins/fusion/server.py @@ -39,6 +39,7 @@ import electroncash.schnorr as schnorr from electroncash.address import Address +from electroncash import networks from electroncash.util import PrintError, ServerError, TimeoutException from . import fusion_pb2 as pb from . import compatibility @@ -232,6 +233,7 @@ def __init__(self, config, network, bindhost, port, upnp = None, announcehost = super().__init__(bindhost, port, ClientThread, upnp = upnp) self.config = config self.network = network + self.is_testnet = networks.net.TESTNET self.announcehost = announcehost self.donation_address = donation_address self.waiting_pools = {t: WaitingPool(Params.min_clients, Params.max_tier_client_tags) for t in Params.tiers} @@ -343,8 +345,9 @@ def new_client_job(self, client): start_ev = threading.Event() client.start_ev = start_ev - if client_ip.startswith('127.'): + if self.is_testnet or client_ip.startswith('127.'): # localhost is whitelisted to allow unlimited access + # we also allow unlimited access for testnets client.tags = [] else: # Default tag: this IP cannot be present in too many fuses. diff --git a/electroncash_plugins/fusion/util.py b/electroncash_plugins/fusion/util.py index 16a497514697..5eb6706f6f9d 100644 --- a/electroncash_plugins/fusion/util.py +++ b/electroncash_plugins/fusion/util.py @@ -36,6 +36,7 @@ import hashlib import ecdsa +from typing import Union, Tuple # Internally used exceptions, shouldn't leak out of this plugin. class FusionError(Exception): @@ -165,3 +166,11 @@ def rand_position(seed, num_positions, counter): """ int64 = int.from_bytes(sha256(seed + counter.to_bytes(4, 'big'))[:8], 'big') return (int64 * num_positions) >> 64 + + +def get_coin_name(coin: dict, also_return_txid_n=False) -> Union[str, Tuple[str, str, int]]: + tx_id, n = coin['prevout_hash'], coin['prevout_n'] + name = "{}:{}".format(tx_id, n) + if not also_return_txid_n: + return name + return name, tx_id, n diff --git a/electroncash_plugins/hw_wallet/cmdline.py b/electroncash_plugins/hw_wallet/cmdline.py index 83c1929217a5..114348bfbd51 100644 --- a/electroncash_plugins/hw_wallet/cmdline.py +++ b/electroncash_plugins/hw_wallet/cmdline.py @@ -8,7 +8,7 @@ def get_passphrase(self, msg, confirm): return getpass.getpass('') def get_pin(self, msg): - t = { 'a':'7', 'b':'8', 'c':'9', 'd':'4', 'e':'5', 'f':'6', 'g':'1', 'h':'2', 'i':'3'} + t = {'a':'7', 'b':'8', 'c':'9', 'd':'4', 'e':'5', 'f':'6', 'g':'1', 'h':'2', 'i':'3'} print_msg(msg) print_msg("a b c\nd e f\ng h i\n-----") o = raw_input() diff --git a/electroncash_plugins/keepkey/keepkey.py b/electroncash_plugins/keepkey/keepkey.py index cb631271cf73..9abdcb1dbf51 100644 --- a/electroncash_plugins/keepkey/keepkey.py +++ b/electroncash_plugins/keepkey/keepkey.py @@ -312,16 +312,16 @@ def get_xpub(self, device_id, derivation, xtype, wizard): return xpub def get_keepkey_input_script_type(self, electrum_txin_type: str): - if electrum_txin_type in ('p2pkh', ): + if electrum_txin_type in ('p2pkh',): return self.types.SPENDADDRESS - if electrum_txin_type in ('p2sh', ): + if electrum_txin_type in ('p2sh',): return self.types.SPENDMULTISIG raise ValueError('unexpected txin type: {}'.format(electrum_txin_type)) def get_keepkey_output_script_type(self, electrum_txin_type: str): - if electrum_txin_type in ('p2pkh', ): + if electrum_txin_type in ('p2pkh',): return self.types.PAYTOADDRESS - if electrum_txin_type in ('p2sh', ): + if electrum_txin_type in ('p2sh',): return self.types.PAYTOMULTISIG raise ValueError('unexpected txin type: {}'.format(electrum_txin_type)) diff --git a/electroncash_plugins/ledger/auth2fa.py b/electroncash_plugins/ledger/auth2fa.py index d7a8a5f0d892..e2bc13786e30 100644 --- a/electroncash_plugins/ledger/auth2fa.py +++ b/electroncash_plugins/ledger/auth2fa.py @@ -15,7 +15,7 @@ "put your cursor into it, and plug your device into that computer. " \ "It will output a summary of the transaction being signed and a one-time PIN.

" \ "Verify the transaction summary and type the PIN code here.

" \ - "Before pressing enter, plug the device back into this computer.
" ), + "Before pressing enter, plug the device back into this computer.
"), _("Verify the address below.
Type the character from your security card corresponding to the BOLD character.") ] @@ -150,7 +150,7 @@ def update_dlg(self): def getDevice2FAMode(self): apdu = [0xe0, 0x24, 0x01, 0x00, 0x00, 0x01] # get 2fa mode try: - mode = self.dongle.exchange( bytearray(apdu) ) + mode = self.dongle.exchange(bytearray(apdu)) return mode except BTChipException as e: print_error('Device getMode Failed') diff --git a/electroncash_plugins/ledger/ledger.py b/electroncash_plugins/ledger/ledger.py index 5d2b5a7e3dec..6ab90db4164c 100644 --- a/electroncash_plugins/ledger/ledger.py +++ b/electroncash_plugins/ledger/ledger.py @@ -180,7 +180,7 @@ def perform_hw1_preflight(self): except BTChipException as e: if (e.sw == 0x6985): self.dongleObject.dongle.close() - self.handler.get_setup( ) + self.handler.get_setup() # Acquire the new client on the next run else: raise e diff --git a/electroncash_plugins/shuffle_deprecated/qt.py b/electroncash_plugins/shuffle_deprecated/qt.py index 6e6d51e20d55..e019d8a5eeca 100644 --- a/electroncash_plugins/shuffle_deprecated/qt.py +++ b/electroncash_plugins/shuffle_deprecated/qt.py @@ -66,8 +66,33 @@ def network_callback(window, event, *args): if len(args) == 2 and hasattr(window, 'wallet') and args[1] is window.wallet and args[0]: window._shuffle_sigs.tx.emit(window, args[0]) +def find_utxo_list_shuffle_column(utxo_list, *, just_column=False): + label_text = getattr(utxo_list, '_shuffle_patched_', None) + if label_text is None: + return + header = utxo_list.headerItem() + if just_column: + # fast path, query just the column + col_ct = header.columnCount() + if not col_ct: + return + # iterate in reverse (it's likely last) + for i in range(col_ct - 1, -1, -1): + if header.text(i) == label_text: + return i + else: + header_labels = [header.text(i) for i in range(header.columnCount())] + col = len(header_labels) - 1 + # find the column, iterate in reverse since it's likely last + for i, lbl in enumerate(reversed(header_labels)): + if lbl == label_text: + col = len(header_labels) - 1 - i + break + return col, header, header_labels + def my_custom_item_setup(utxo_list, item, utxo, name): - if not hasattr(utxo_list.wallet, 'is_coin_shuffled'): + col = find_utxo_list_shuffle_column(utxo_list, just_column=True) + if col is None: return prog = utxo_list.in_progress.get(name, "") @@ -78,51 +103,51 @@ def my_custom_item_setup(utxo_list, item, utxo, name): u_value = utxo['value'] if is_slp: - item.setText(5, _("SLP Token")) + item.setText(col, _("SLP Token")) elif not is_reshuffle and utxo_list.wallet.is_coin_shuffled(utxo): # already shuffled - item.setText(5, _("Shuffled")) + item.setText(col, _("Shuffled")) elif not is_reshuffle and utxo['address'] in utxo_list.wallet._shuffled_address_cache: # we hit the cache directly as a performance hack. we don't really need a super-accurate reply as this is for UI and the cache will eventually be accurate - item.setText(5, _("Shuffled Addr")) + item.setText(col, _("Shuffled Addr")) elif not prog and ("a" in frozenstring or "c" in frozenstring): - item.setText(5, _("Frozen")) + item.setText(col, _("Frozen")) elif u_value >= BackgroundShufflingThread.UPPER_BOUND: # too big - item.setText(5, _("Too big")) + item.setText(col, _("Too big")) elif u_value < BackgroundShufflingThread.LOWER_BOUND: # too small - item.setText(5, _("Too small")) + item.setText(col, _("Too small")) elif utxo['height'] <= 0: # not_confirmed if is_reshuffle: - item.setText(5, _("Unconfirmed (reshuf)")) + item.setText(col, _("Unconfirmed (reshuf)")) else: - item.setText(5, _("Unconfirmed")) + item.setText(col, _("Unconfirmed")) elif utxo['coinbase']: # we disallow coinbase coins unconditionally -- due to miner feedback (they don't like shuffling these) - item.setText(5, _("Coinbase")) + item.setText(col, _("Coinbase")) elif (u_value >= BackgroundShufflingThread.LOWER_BOUND and u_value < BackgroundShufflingThread.UPPER_BOUND): # queued_labels window = utxo_list.parent if (window and window.background_process and utxo_list.wallet.network and utxo_list.wallet.network.is_connected()): if window.background_process.get_paused(): - item.setText(5, _("Paused")) + item.setText(col, _("Paused")) else: if is_reshuffle: - item.setText(5, _("In queue (reshuf)")) + item.setText(col, _("In queue (reshuf)")) else: - item.setText(5, _("In queue")) + item.setText(col, _("In queue")) else: - item.setText(5, _("Offline")) + item.setText(col, _("Offline")) if prog == 'in progress': # in progress - item.setText(5, _("In progress")) + item.setText(col, _("In progress")) elif prog.startswith('phase '): - item.setText(5, _("Phase {}").format(prog.split()[-1])) + item.setText(col, _("Phase {}").format(prog.split()[-1])) elif prog == 'wait for others': # wait for others - item.setText(5, _("Wait for others")) + item.setText(col, _("Wait for others")) elif prog.startswith("got players"): # got players > 1 num, tot = (int(x) for x in prog.rsplit(' ', 2)[-2:]) txt = "{} ({}/{})".format(_("Players"), num, tot) - item.setText(5, txt) + item.setText(col, txt) elif prog == "completed": - item.setText(5, _("Done")) + item.setText(col, _("Done")) def my_custom_utxo_context_menu_setup(window, utxo_list, menu, selected): ''' Adds CashShuffle related actions to the utxo_list context (right-click) @@ -429,14 +454,14 @@ class Sigs(QObject): print_error("[shuffle] Patched window") def patch_utxo_list(utxo_list): - if getattr(utxo_list, '_shuffle_patched_', None): + if getattr(utxo_list, '_shuffle_patched_', None) is not None: return header = utxo_list.headerItem() header_labels = [header.text(i) for i in range(header.columnCount())] header_labels.append(_("Shuffle status")) utxo_list.update_headers(header_labels) utxo_list.in_progress = dict() - utxo_list._shuffle_patched_ = True + utxo_list._shuffle_patched_ = header_labels[-1] # save the text to be able to find the column later print_error("[shuffle] Patched utxo_list") def patch_wallet(wallet): @@ -494,11 +519,11 @@ def restore_window(window): # Note that at this point an additional monkey patch: 'window.__disabled_sendtab_extra__' may stick around until the plugin is unloaded altogether def restore_utxo_list(utxo_list): - if not getattr(utxo_list, '_shuffle_patched_', None): + tup = find_utxo_list_shuffle_column(utxo_list) + if not tup: return - header = utxo_list.headerItem() - header_labels = [header.text(i) for i in range(header.columnCount())] - del header_labels[-1] + col, header, header_labels = tup + del header_labels[col] utxo_list.update_headers(header_labels) utxo_list.in_progress = None delattr(window.utxo_list, "in_progress") @@ -545,7 +570,7 @@ def description(self): return _("CashShuffle Protocol") def is_available(self): - return networks.net is not networks.TaxCoinNet + return True def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) diff --git a/ios/CustomCode/ViewsForIB.h b/ios/CustomCode/ViewsForIB.h index a1031d39fac7..e903818628df 100644 --- a/ios/CustomCode/ViewsForIB.h +++ b/ios/CustomCode/ViewsForIB.h @@ -20,6 +20,8 @@ @end @interface CustomNavController : UINavigationController +@property (class, nonatomic, copy, nullable) UIColor *topNavBGColor; +@property (class, nonatomic, copy, nullable) UIColor *topNavTextColor; @end @interface AddrConvBase : CustomViewController @@ -76,6 +78,7 @@ @property (nonatomic, weak) IBOutlet UIButton *contactBut; @property (nonatomic, weak) IBOutlet UILabel *descTit; @property (nonatomic, weak) IBOutlet UITextView *desc; +@property (nonatomic, weak) IBOutlet UITextView *opReturn; @property (nonatomic, weak) IBOutlet UILabel *amtTit; @property (nonatomic, weak) IBOutlet BTCAmountEdit *amt; @property (nonatomic, weak) IBOutlet UIButton *maxBut; @@ -87,14 +90,17 @@ @property (nonatomic, weak) IBOutlet UIBarButtonItem *clearBut; @property (nonatomic, weak) IBOutlet UIBarButtonItem *previewBut; @property (nonatomic, weak) IBOutlet UIButton *sendBut; // actually a subview of a UIBarButtonItem +@property (nonatomic, weak) IBOutlet UIButton *opReturnToggle; @property (nonatomic, weak) IBOutlet UILabel *message; @property (nonatomic, weak) IBOutlet NSLayoutConstraint *csFeeTop, *csTvHeight, *csPayToTop, *csContentHeight; @property (nonatomic, weak) IBOutlet UITableView *tv; @property (nonatomic, weak) IBOutlet UIView *bottomView, *messageView; @property (nonatomic, strong) IBOutlet ECTextViewDelegate *descDel; +@property (nonatomic, strong) IBOutlet ECTextViewDelegate *opReturnDel; @end @interface SendVC : SendBase +-(IBAction)onToggleRawOpReturn; // implemented in python send.py -(IBAction)onQRBut:(id)sender; // implemented in python send.py -(IBAction)onContactBut:(id)sender; // implemented in python send.py -(IBAction)clear; // implemented in python send.py diff --git a/ios/CustomCode/ViewsForIB.m b/ios/CustomCode/ViewsForIB.m index 6d13b9405aed..94d1d89762ff 100644 --- a/ios/CustomCode/ViewsForIB.m +++ b/ios/CustomCode/ViewsForIB.m @@ -24,6 +24,35 @@ static void applyWorkaround(UIViewController *vc) { vc.modalPresentationStyle = UIModalPresentationFullScreen; NSLog(@"iOS 13+ workaround: forcing presentation style to fullscreen for %@", [vc description]); } + // Another workaround for iOS 15+ breaking stuff. :( + // Ugh. see: https://developer.apple.com/forums/thread/682420 + if (@available(iOS 15, *)) { + if ([vc isKindOfClass:[UINavigationController class]]) { + UINavigationController *nav = (UINavigationController *)vc; + UINavigationBar *bar = nav.navigationBar; + if (bar) { + UINavigationBarAppearance *appearance = [UINavigationBarAppearance new]; + [appearance configureWithOpaqueBackground]; + // HACK!! + if (CustomNavController.topNavBGColor) { + appearance.backgroundColor = CustomNavController.topNavBGColor; + } + if (CustomNavController.topNavTextColor) { + NSMutableDictionary *dict = nil; + if (appearance.titleTextAttributes) + dict = [NSMutableDictionary dictionaryWithDictionary:appearance.titleTextAttributes]; + else + dict = [NSMutableDictionary new]; + [dict setValue:[CustomNavController.topNavTextColor copy] forKey:NSForegroundColorAttributeName]; + appearance.titleTextAttributes = dict; + } + bar.standardAppearance = appearance; + bar.scrollEdgeAppearance = bar.standardAppearance; + NSLog(@"iOS 15+ workaround: applying navBar standardAppearance workaround for %@", + [vc description]); + } + } + } } @implementation CustomViewController @@ -82,6 +111,14 @@ - (void)presentViewController:(UIViewController *)viewControllerToPresent applyWorkaround(viewControllerToPresent); [super presentViewController:viewControllerToPresent animated:flag completion:completion]; } +// Class properties +static UIColor *s_topNavBGColor = nil; ++ (void) setTopNavBGColor:(UIColor *)c { s_topNavBGColor = [c copy]; } ++ (UIColor *) topNavBGColor { return s_topNavBGColor; } + +static UIColor *s_topNavTextColor = nil; ++ (void) setTopNavTextColor:(UIColor *)c { s_topNavTextColor = [c copy]; } ++ (UIColor *) topNavTextColor { return s_topNavTextColor; } @end @implementation AddrConvBase diff --git a/ios/ElectronCash/app.py b/ios/ElectronCash/app.py index 6658e0a0ed80..ab6775112c75 100644 --- a/ios/ElectronCash/app.py +++ b/ios/ElectronCash/app.py @@ -5,7 +5,7 @@ # MIT License # import os -from electroncash_gui.ios_native.monkeypatches import MonkeyPatches +from electroncash_gui.ios_native.monkeypatches import MonkeyPatches, PatchedSimpleConfig from electroncash.util import set_verbosity from electroncash_gui.ios_native import ElectrumGui from electroncash_gui.ios_native.utils import call_later, get_user_dir, cleanup_tmp_dir, is_debug_build, NSLogSuppress, NSLog @@ -21,7 +21,6 @@ def main(): 'cmd': 'gui', 'gui': 'ios_native', 'cwd': os.getcwd(), - 'whitelist_servers_only' : True, # on iOS we force only the whitelist ('preferred') servers only for now as a security measure 'testnet': 'EC_TESTNET' in os.environ, # You can set the env when testing using Xcode "Scheme" editor } @@ -35,7 +34,7 @@ def main(): MonkeyPatches.patch() - config = SimpleConfig(config_options, read_user_dir_function = get_user_dir) + config = PatchedSimpleConfig(config_options, read_user_dir_function=get_user_dir) gui = ElectrumGui(config) call_later(0.010, gui.main) # this is required for the activity indicator to actually animate. Switch to a direct call if not using activity indicator on Splash2 diff --git a/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py b/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py index 5cca2c245eac..7e69ccd1d96e 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py @@ -117,13 +117,13 @@ def autosizeUnitLabel(self) -> None: self.unitLabel.frame = f supf = f supf.size.width += 10.0 # 10 pix padding on right - self.unitLabel.superview().frame = supf + utils.boilerplate.get_superview(self.unitLabel).frame = supf spacf = CGRectMake(f.size.width,0.0,10.0,f.size.height) - self.unitLabel.superview().viewWithTag_(2).frame = spacf + utils.boilerplate.get_superview(self.unitLabel).viewWithTag_(2).frame = spacf else: # unit label has a fixed size, with a 10 pix padding w = py_from_ns(self.fixedUnitLabelWidth) - sup = self.unitLabel.superview() + sup = utils.boilerplate.get_superview(self.unitLabel) spac = sup.viewWithTag_(2) sz = self.unitLabel.attributedText.size() sz.width = w @@ -136,7 +136,7 @@ def autosizeUnitLabel(self) -> None: def leftViewRectForBounds_(self, bounds : CGRect) -> CGRect: r = send_super(__class__, self, 'leftViewRectForBounds:', bounds, argtypes=[CGRect], restype=CGRect) if self.unitLabel: - sz = self.unitLabel.superview().bounds.size + sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size return CGRectOffset(r, bounds.size.width - sz.width, 0) return r @@ -144,7 +144,7 @@ def leftViewRectForBounds_(self, bounds : CGRect) -> CGRect: def clearButtonRectForBounds_(self, bounds : CGRect) -> CGRect: r = send_super(__class__, self, 'clearButtonRectForBounds:', bounds, argtypes=[CGRect], restype=CGRect) if self.unitLabel: - sz = self.unitLabel.superview().bounds.size + sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size return CGRectOffset(r, -sz.width, 0) return r @@ -153,7 +153,7 @@ def editingRectForBounds_(self, bounds : CGRect) -> CGRect: r = bounds r.origin.x = 0 if self.unitLabel: - sz = self.unitLabel.superview().bounds.size + sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size r.size.width -= (20 + sz.width) return r @@ -162,7 +162,7 @@ def textRectForBounds_(self, bounds : CGRect) -> CGRect: rect = bounds rect.origin.x = 0 if self.unitLabel: - sz = self.unitLabel.superview().bounds.size + sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size rect.size.width -= (20 + sz.width) return rect diff --git a/ios/ElectronCash/electroncash_gui/ios_native/gui.py b/ios/ElectronCash/electroncash_gui/ios_native/gui.py index 0530f2c84c99..a6d0816dc4b6 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/gui.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/gui.py @@ -281,6 +281,10 @@ def __init__(self, config): utils.NSLog("GUI instance created, splash screen 2 presented") def createAndShowUI(self): + # First we set-up the iOS 15+ work-around colors.. + CustomNavController.topNavBGColor = utils.uicolor_custom('nav') + CustomNavController.topNavTextColor = UIColor.whiteColor + self.helper = GuiHelper.alloc().init() self.tabController = MyTabBarController.alloc().init().autorelease() @@ -1238,9 +1242,15 @@ def pay_to_URI(self, URI, showErr : bool = True) -> bool: amount = out.get('amount') label = out.get('label') message = out.get('message') + op_return = out.get('op_return') + op_return_raw = out.get('op_return_raw') + op_return_is_raw = False + if op_return_raw is not None: + op_return_is_raw = True + op_return = op_return_raw # use label as description (not BIP21 compliant) if self.sendVC: - self.sendVC.onPayTo_message_amount_(address,message,amount) + self.sendVC.onPayTo_message_amount_opReturn_isRaw_(address,message,amount,op_return,op_return_is_raw) return True else: self.show_error("Oops! Something went wrong! Email the developers!") @@ -1474,9 +1484,13 @@ def stop_daemon(self): self.daemon = None self.dismiss_downloading_notif() utils.cleanup_tmp_dir() + wd = wallets.WalletsMgr.wallets_dir() + if wd: utils.cleanup_wallet_dir(wd) # on newer iOS for some reason *.tmp.PID remain.. def start_daemon(self): if self.daemon_is_running(): return + wd = wallets.WalletsMgr.wallets_dir() + if wd: utils.cleanup_wallet_dir(wd) # on newer iOS for some reason *.tmp.PID remain.. import electroncash.daemon as ed try: # Force remove of lock file so the code below cuts to the chase and starts a new daemon without diff --git a/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py b/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py index 713adffde101..572f19914040 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py @@ -12,11 +12,14 @@ Monkey Patches -- mostly to modify electroncash.* package to suit our needs. Don't hate me. (This was needed to keep the iOS stuff self-contained.) ''' +import ssl +import sys from .uikit_bindings import * from electroncash.util import (InvalidPassword, profiler) import electroncash.bitcoin as ec_bitcoin +from electroncash.simple_config import SimpleConfig from electroncash_gui.ios_native.utils import NSLog -import sys, ssl + class MonkeyPatches: @@ -156,3 +159,14 @@ def TEST(cls): data2 = olddec(key,iv,cypher) print("data=",data,"data2=",data2,'cypher=',cypher) ''' + + +class PatchedSimpleConfig(SimpleConfig): + """ We restore the "max_fee_rate" method to its original in this patched version, to allow the prefs + "Max Static Fee" widget to actually do something. """ + + def max_fee_rate(self): + f = self.get('max_fee_rate', ec_bitcoin.MAX_FEE_RATE) + if f == 0: + f = ec_bitcoin.MAX_FEE_RATE + return f diff --git a/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py b/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py index 5acde81e6ed0..7cf72648330a 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py @@ -146,16 +146,16 @@ def onTfChg(oid : objc_id) -> None: onTfChgBlock = Block(onTfChg) for v in views: tag = v.tag - if isinstance(v, UIButton) and tag in BUTTON_TAGS: + if isinstance(v, UIButton) and tag == BUTTON_TAGS: v.handleControlEvent_withBlock_(UIControlEventPrimaryActionTriggered, showHelpBlock) - elif isinstance(v, UISwitch) and tag is TAG_AUTOSERVER_SW: + elif isinstance(v, UISwitch) and tag == TAG_AUTOSERVER_SW: self.autoServerSW = v v.handleControlEvent_withBlock_(UIControlEventPrimaryActionTriggered, onAutoServerSW) elif isinstance(v, UITextField) and tag in (TAG_HOST_TF, TAG_PORT_TF): v.delegate = self - if tag is TAG_HOST_TF: self.hostTF = v - elif tag is TAG_PORT_TF: self.portTF = v - v.handleControlEvent_withBlock_(UIControlEventEditingChanged,onTfChgBlock) + if tag == TAG_HOST_TF: self.hostTF = v + elif tag == TAG_PORT_TF: self.portTF = v + v.handleControlEvent_withBlock_(UIControlEventEditingChanged, onTfChgBlock) # assign views we are interested in to our properties if tag == TAG_SERVER_LBL: self.serverLbl = v elif tag == TAG_STATUS_LBL: self.statusLbl = v @@ -472,7 +472,8 @@ def doSetServer(self) -> None: host, port, protocol, proxy, auto_connect = network.get_parameters() host = str(self.hostTF.text) port = str(self.portTF.text) - auto_connect = self.autoServerSW.isOn() + auto_connect = bool(self.autoServerSW.isOn()) + network.set_whitelist_only(auto_connect) network.set_parameters(host, port, protocol, proxy, auto_connect) @objc_method @@ -488,16 +489,16 @@ def setServer_(self, s : ObjCInstance) -> None: def showHelpForButton(oid : objc_id) -> None: tag = int(ObjCInstance(oid).tag) msg = _("Unknown") - if tag is TAG_HELP_STATUS: + if tag == TAG_HELP_STATUS: msg = ' '.join([ _("Electron Cash connects to several nodes in order to download block headers and find out the longest blockchain."), _("This blockchain is used to verify the transactions sent by your transaction server.") ]) - elif tag is TAG_HELP_SERVER: + elif tag == TAG_HELP_SERVER: msg = _("Electron Cash sends your wallet addresses to a single server, in order to receive your transaction history.") - elif tag is TAG_HELP_BLOCKCHAIN: + elif tag == TAG_HELP_BLOCKCHAIN: msg = _('This is the height of your local copy of the blockchain.') - elif tag is TAG_HELP_AUTOSERVER: + elif tag == TAG_HELP_AUTOSERVER: msg = ' '.join([ _("If auto-connect is enabled, Electron Cash will always use a server that is on the longest blockchain."), _("If it is disabled, you have to choose a server you want to use. Electron Cash will warn you if your server is lagging.") diff --git a/ios/ElectronCash/electroncash_gui/ios_native/receive.py b/ios/ElectronCash/electroncash_gui/ios_native/receive.py index bcf739e84fbf..a297c4fc4ae8 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/receive.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/receive.py @@ -12,7 +12,7 @@ from electroncash.util import timestamp_to_datetime, format_time from electroncash.i18n import _, language from electroncash.address import Address, ScriptOutput -from electroncash.paymentrequest import PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID +from electroncash.paymentrequest import PR_UNCONFIRMED, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID from electroncash import bitcoin import electroncash.web as web import sys, traceback, time @@ -25,13 +25,15 @@ pr_icons = { PR_UNPAID:"unpaid.png", PR_PAID:"confirmed.png", - PR_EXPIRED:"expired.png" + PR_EXPIRED:"expired.png", + PR_UNCONFIRMED:"unconfirmed.png", } pr_tooltips = { PR_UNPAID:'Pending', PR_PAID:'Paid', - PR_EXPIRED:'Expired' + PR_EXPIRED:'Expired', + PR_UNCONFIRMED:'Unconfirmed', } ReqItem = namedtuple("ReqItem", "dateStr addrStr signedBy message amountStr statusStr addr iconSign iconStatus fiatStr timestamp expiration expirationStr amount fiat") diff --git a/ios/ElectronCash/electroncash_gui/ios_native/send.py b/ios/ElectronCash/electroncash_gui/ios_native/send.py index 311b1297e031..822a314d4f59 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/send.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/send.py @@ -18,6 +18,7 @@ from electroncash import networks from electroncash.address import Address, ScriptOutput from electroncash.paymentrequest import PaymentRequest +from electroncash.transaction import OPReturn from electroncash import bitcoin from .feeslider import FeeSlider from .amountedit import BTCAmountEdit @@ -60,6 +61,7 @@ class SendVC(SendBase): dismissOnAppear = objc_property() kbas = objc_property() queuedPayTo = objc_property() + opReturnIsRaw = objc_property() @objc_method def init(self): @@ -75,6 +77,7 @@ def init(self): self.dismissOnAppear = False self.kbas = None self.queuedPayTo = None + self.opReturnIsRaw = False self.navigationItem.leftItemsSupplementBackButton = True bb = UIBarButtonItem.new().autorelease() @@ -98,6 +101,7 @@ def dealloc(self) -> None: self.excessiveFee = None self.kbas = None self.queuedPayTo = None + self.opReturnIsRaw = None utils.nspy_pop(self) for e in [self.amt, self.fiat, self.payTo]: if e: utils.nspy_pop(e) @@ -209,10 +213,16 @@ def onManualFee(t : ObjCInstance) -> None: # Error Label self.message.text = "" + self.opReturnDel.placeholderFont = UIFont.italicSystemFontOfSize_(14.0) + self.opReturnDel.tv = self.opReturn + self.opReturnDel.text = "" + self.opReturnDel.placeholderText = _("OP_RETURN data (optional).") + self.descDel.placeholderFont = UIFont.italicSystemFontOfSize_(14.0) self.descDel.tv = self.desc self.descDel.text = "" self.descDel.placeholderText = _("Description of the transaction (not mandatory).") + feelbl = self.feeLbl slider = self.feeSlider @@ -260,7 +270,7 @@ def viewWillAppear_(self, animated : bool) -> None: try: qpt = list(self.queuedPayTo) self.queuedPayTo = None - self.onPayTo_message_amount_(qpt[0],qpt[1],qpt[2]) + self.onPayTo_message_amount_opReturn_isRaw_(qpt[0],qpt[1],qpt[2],qpt[3],qpt[4]) except: utils.NSLog("queuedPayTo.. failed with exception: %s",str(sys.exc_info()[1])) @@ -403,9 +413,13 @@ def textFieldShouldReturn_(self, tf : ObjCInstance) -> bool: @objc_method def onPayTo_message_amount_(self, address, message, amount) -> None: + return self.onPayTo_message_amount_opReturn_isRaw_(address, message, amount, None, False) + + @objc_method + def onPayTo_message_amount_opReturn_isRaw_(self, address, message, amount, op_return, op_return_is_raw) -> None: # address if not self.viewIfLoaded: - self.queuedPayTo = [address, message, amount] + self.queuedPayTo = [address, message, amount, op_return, op_return_is_raw] return tf = self.payTo pr = get_PR(self) @@ -433,10 +447,15 @@ def onPayTo_message_amount_(self, address, message, amount) -> None: tf.resignFirstResponder() utils.uitf_redo_attrs(tf) utils.uitf_redo_attrs(self.fiat) + # op_return + self.opReturnDel.text = str(op_return) if op_return is not None else "" + self.opReturnIsRaw = bool(op_return_is_raw) + self.opReturnToggle.setSelected_(self.opReturnIsRaw) + self.opReturn.resignFirstResponder() self.qrScanErr = False self.chkOk() - utils.NSLog("OnPayTo %s %s %s",str(address), str(message), str(amount)) + utils.NSLog("OnPayTo %s %s %s %s %s",str(address), str(message), str(amount), str(op_return), str(op_return_is_raw)) @objc_method def chkOk(self) -> bool: @@ -453,7 +472,7 @@ def chkOk(self) -> bool: self.csPayToTop.constant = 0 f = self.desc.frame - self.csContentHeight.constant = f.origin.y + f.size.height + 125 + self.csContentHeight.constant = f.origin.y + f.size.height + 255 retVal = False errLbl = self.message @@ -527,6 +546,7 @@ def clearAllExceptSpendFrom(self) -> None: tf = self.fiat # label self.descDel.text = "" + self.opReturnDel.text = "" # slider slider = self.feeSlider slider.setValue_animated_(slider.minimumValue,True) @@ -762,6 +782,11 @@ def get_dummy(): utils.uitf_redo_attrs(amount_e) self.chkOk() + @objc_method + def onToggleRawOpReturn(self) -> None: + self.opReturnIsRaw = not bool(self.opReturnIsRaw) + self.opReturnToggle.setSelected_(self.opReturnIsRaw) + @objc_method def onPreviewSendBut_(self, but) -> None: self.view.endEditing_(True) @@ -1021,6 +1046,20 @@ def read_send_form(send : ObjCInstance) -> tuple: # if not self.question(msg): # return + try: + opreturn_message = send.opReturnDel.text + if opreturn_message: + if send.opReturnIsRaw: + outputs.append(OPReturn.output_for_rawhex(opreturn_message)) + else: + outputs.append(OPReturn.output_for_stringdata(opreturn_message)) + except OPReturn.TooLarge as e: + utils.show_alert(send, _("Error"), str(e)) + return None + except OPReturn.Error as e: + utils.show_alert(send, _("Error"), str(e)) + return None + if not outputs: utils.show_alert(send, _("Error"), _('No outputs')) return None diff --git a/ios/ElectronCash/electroncash_gui/ios_native/utils.py b/ios/ElectronCash/electroncash_gui/ios_native/utils.py index 6031ca81eb7f..c7e60ab6fd66 100644 --- a/ios/ElectronCash/electroncash_gui/ios_native/utils.py +++ b/ios/ElectronCash/electroncash_gui/ios_native/utils.py @@ -127,6 +127,30 @@ def cleanup_tmp_dir(): if tot: NSLog("Cleanup Tmp Dir: removed %d/%d files from tmp dir in %f ms",ct,tot,(time.time()-t0)*1e3) +def cleanup_wallet_dir(wallet_dir: str): + t0 = time.time() + ct = tot = 0 + import glob + import re + if os.path.isdir(wallet_dir): + it = glob.iglob(os.path.join(wallet_dir,'*.tmp.*')) + for f in it: + parts = f.split('.') + if (len(parts) >= 3 and re.match(r'\d+', parts[-1]) and parts[-2] == "tmp" + and os.path.exists('.'.join(parts[0:-2]))): + tot += 1 + try: + os.remove(f) + ct += 1 + except Exception as e: + NSLog("Cleanup Wallet Dir: failed to remove wallet file: %s -- exception: %s", + f, str(e)) + + if tot: + NSLog("Cleanup Wallet Dir: removed %d/%d tmp files from wallet dir in %f ms " + "(%d wallets left untouched)", + ct, tot, (time.time() - t0) * 1e3, wallet_ct) + def ios_version_string() -> str: return "%s %s %s (%s)"%ios_version_tuple_full() @@ -516,7 +540,8 @@ def onCompletion() -> None: vc.presentViewController_animated_completion_(alert,animated,onCompletion) if localRunLoop: while not got_callback: - NSRunLoop.currentRunLoop().runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.1)) + crl = send_message(ObjCClass("NSRunLoop"), "currentRunLoop") + ObjCInstance(crl).runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.1)) return None return alert @@ -611,7 +636,8 @@ def OnTimer(t_in : objc_id) -> None: func(*args) if t: t.invalidate() timer = NSTimer.timerWithTimeInterval_repeats_block_(timeout, False, OnTimer) - NSRunLoop.mainRunLoop().addTimer_forMode_(timer, NSDefaultRunLoopMode) + mrl = send_message(ObjCClass("NSRunLoop"), "mainRunLoop") + ObjCInstance(mrl).addTimer_forMode_(timer, NSDefaultRunLoopMode) return timer ### @@ -1437,7 +1463,7 @@ def willShow_(self, sender) -> None: entry = _kbcb_dict.get(self.handle, None) if not entry: return rect = py_from_ns(sender.userInfo)[str(UIKeyboardFrameEndUserInfoKey)].CGRectValue - window = entry.view.window() + window = boilerplate.get_window(entry.view) if window: rect = entry.view.convertRect_fromView_(rect, window) if entry.onWillShow: entry.onWillShow(rect) @objc_method @@ -1445,7 +1471,7 @@ def didShow_(self, sender) -> None: entry = _kbcb_dict.get(self.handle, None) if not entry: return rect = py_from_ns(sender.userInfo)[str(UIKeyboardFrameEndUserInfoKey)].CGRectValue - window = entry.view.window() + window = boilerplate.get_window(entry.view) if window: rect = entry.view.convertRect_fromView_(rect, window) if entry.onDidShow: entry.onDidShow(rect) @@ -1493,8 +1519,8 @@ def register_keyboard_autoscroll(sv : UIScrollView) -> int: return None def kbShow(r : CGRect) -> None: resp = UIResponder.currentFirstResponder() - window = sv.window() - if resp and isinstance(resp, UIView) and window and resp.window(): + window = boilerplate.get_window(sv) + if resp and isinstance(resp, UIView) and window and boilerplate.get_window(resp): #r = sv.convertRect_toView_(r, window) visible = sv.convertRect_toView_(sv.bounds, window) visible.size.height -= r.size.height @@ -1759,10 +1785,10 @@ def vc_highlight_button_then_do(vc : UIViewController, but : UIButton, func : C # Layout constraint stuff.. programatically @staticmethod def layout_peg_view_to_superview(view : UIView) -> None: - if not view.superview(): + if not boilerplate.get_superview(view): NSLog("Warning: layout_peg_view_to_superview -- passed-in view lacks a superview!") return - sv = view.superview() + sv = boilerplate.get_superview(view) sv.addConstraint_(NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( sv, NSLayoutAttributeCenterX, NSLayoutRelationEqual, view, NSLayoutAttributeCenterX, 1.0, 0.0 )) sv.addConstraint_(NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_( @@ -1782,6 +1808,25 @@ def create_and_add_blur_view(parent : UIView, effectStyle = UIBlurEffectStyleReg parent.addSubview_(blurView) return blurView + @staticmethod + def get_superview(view: UIView) -> ObjCInstance: + """This used to be a method now it's a property, so we need to use send_message + in case on some older iOS it's still a method""" + ret = send_message(view, "superview") + if ret: + ret = ObjCInstance(ret) + return ret + + @staticmethod + def get_window(view: UIView) -> ObjCInstance: + """This used to be a method now it's a property, so we need to use send_message + in case on some older iOS it's still a method""" + ret = send_message(view, "window") + if ret: + ret = ObjCInstance(ret) + return ret + + ### ### iOS13 Status Bar Workaround stuff ### diff --git a/ios/README.rst b/ios/README.rst index 3aca081e7665..bd5f5f03428d 100644 --- a/ios/README.rst +++ b/ios/README.rst @@ -22,7 +22,7 @@ Quick Start Instructions python3 -m pip install 'cookiecutter==1.6.0' --user --upgrade python3 -m pip install 'briefcase==0.2.6' --user --upgrade python3 -m pip install 'pbxproj==2.5.1' --user --upgrade - pyrhon3 -m pip install 'rubicon-objc==0.2.10' --user --upgrade + python3 -m pip install 'rubicon-objc==0.2.10' --user --upgrade (NOTE: The exact versions specified above are known to work, but you may also try and use newer version as well.) @@ -62,4 +62,4 @@ If you want to run the app to point to the BCH TestNet network: Additional Notes ---------------- -The app built by this Xcode project is a fully running standalone Electron Cash as an iPhone app. It pulls in sources from ../lib and other places when generating the Xcode project, but everything that is needed (.py files, Python interpreter, etc) ends up packaged in the generated iOS .app! +The app built by this Xcode project is a fully running standalone Electron Cash as an iPhone app. It pulls in sources from `../electroncash` and other places when generating the Xcode project, but everything that is needed (.py files, Python interpreter, etc) ends up packaged in the generated iOS .app! diff --git a/ios/Resources/Send.xib b/ios/Resources/Send.xib index 6f95a007c36b..39dfff9a9bc4 100644 --- a/ios/Resources/Send.xib +++ b/ios/Resources/Send.xib @@ -33,6 +33,9 @@ + + + @@ -61,7 +64,7 @@ - + @@ -439,6 +489,20 @@ + + + + + + + + + + + + + + @@ -448,5 +512,7 @@ + + diff --git a/ios/Resources/WalletsTab.xib b/ios/Resources/WalletsTab.xib index ee60b4312772..d61dbc0a3b8e 100644 --- a/ios/Resources/WalletsTab.xib +++ b/ios/Resources/WalletsTab.xib @@ -1,18 +1,16 @@ - - - - + + - + - + @@ -21,6 +19,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -32,7 +73,7 @@ - + @@ -54,7 +95,7 @@ - + @@ -150,7 +191,7 @@ - -