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..2aae4b053cd5 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/ColdLoad.kt @@ -2,8 +2,12 @@ package org.electroncash.electroncash3 import android.content.ClipboardManager import android.content.Intent +import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AlertDialog +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.load.* @@ -17,12 +21,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) + .setPositiveButton(R.string.OK, null) } override fun onShowDialog() { @@ -42,16 +47,33 @@ 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 = libTransaction.callAttr("Transaction", etTransaction.text.toString()) + updateStatusText(tx) + + // Check hex transaction signing status + if (canSign(tx)) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.sign) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } else if (canBroadcast(tx)) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.send) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } else { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } } // 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) } @@ -59,6 +81,36 @@ class ColdLoadDialog : AlertDialogFragment() { fun onOK() { val tx = libTransaction.callAttr("Transaction", etTransaction.text.toString()) + + // If transaction can be broadcasted, broadcast it. + // Otherwise, prompt for signing. If the transaction hex is invalid, + // the OK button will be disabled, regardless. + try { + if (canBroadcast(tx)) { + broadcastSignedTransaction(tx) + } else { + signLoadedTransaction() + } + } catch (e: ToastException) { + e.show() + } + } + + /** + * Sign a loaded transaction. + */ + private fun signLoadedTransaction() { + val arguments = Bundle().apply { + putString("txHex", etTransaction.text.toString()) + } + val dialog = SendDialog() + showDialog(this, dialog.apply { setArguments(arguments) }) + } + + /** + * Broadcast a signed transaction. + */ + private fun broadcastSignedTransaction(tx: PyObject) { try { if (!daemonModel.isConnected()) { throw ToastException(R.string.not_connected) @@ -69,4 +121,48 @@ class ColdLoadDialog : AlertDialogFragment() { dismiss() } catch (e: ToastException) { e.show() } } -} \ No newline at end of file + + + /** + * Check if a loaded transaction is signed. + * Displays the signing status below the raw TX field. + * (signed, partially signed, empty or invalid) + */ + private fun updateStatusText(tx: PyObject) { + try { + if (etTransaction.text.isBlank()) { + idTxStatus.setText(R.string.empty) + } else { + // Check if the transaction can be processed by this wallet or not + 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) + } + } +} + +/* Check if the wallet can sign the transaction */ +fun canSign(tx: PyObject): Boolean { + return try { + !tx.callAttr("is_complete").toBoolean() and + daemonModel.wallet!!.callAttr("can_sign", tx).toBoolean() + } catch (e: PyException) { + false + } +} + +/* Check if the transaction is ready to be broadcasted */ +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/Daemon.kt b/android/app/src/main/java/org/electroncash/electroncash3/Daemon.kt index 1b8b0b92c1a5..b508d9f91a4e 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 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..ed89380cf611 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Main.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Main.kt @@ -17,6 +17,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.RadioButton import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -27,7 +28,9 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.observe import com.chaquo.python.Kwarg 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 @@ -231,6 +234,8 @@ class MainActivity : AppCompatActivity(R.layout.main) { } R.id.menuChangePassword -> showDialog(this, PasswordChangeDialog()) R.id.menuShowSeed -> { showDialog(this, SeedPasswordDialog()) } + R.id.menuWalletInformation -> { showDialog(this, + WalletInformationDialog().apply { arguments = Bundle() }) } R.id.menuExportSigned -> { try { showDialog(this, SendDialog().apply { @@ -654,15 +659,83 @@ 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) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + fabCopyMasterKey2.setOnClickListener { + val textToCopy = walletMasterKey.text + copyToClipboard(textToCopy, R.string.Master_public_key) + } + } + + override fun onShowDialog() { + super.onShowDialog() + + displayMasterPublicKeys() + idWalletName.setText(daemonModel.walletName) + idWalletType.setText(daemonModel.walletType) + idScriptType.setText(daemonModel.scriptType) + } + + private fun displayMasterPublicKeys() { + val mpks = daemonModel.wallet!!.callAttr("get_master_public_keys")?.asList() + + if (mpks != null && mpks.size != 0) { + walletMasterKey.setFocusable(false) + // For multisig wallets, display a radio group with selectable cosigners. + if (mpks.size > 1) { + rgCosigners.setVisibility(View.VISIBLE) + tvMasterPublicKey.setText(R.string.Master_public_keys) + + for ((i, mpk) in mpks.withIndex()) { + val rb = RadioButton(dialog.context) + rb.setText(getString(R.string.cosigner__d, i + 1)) + rb.setOnClickListener { + walletMasterKey.setText(mpk.toString()) + arguments?.putInt("selected", i) + } + rgCosigners.addView(rb) + // Set the first cosigner as selected. + if (arguments?.getInt("selected") == null && i == 0) { + walletMasterKey.setText(mpk.toString()) + rgCosigners.check(rb.id) + } + } + + // Preserve the selected cosigner across rotations + val selected = arguments?.getInt("selected") + if (selected != null) { + rgCosigners.getChildAt(selected).performClick() + } + } else { + // For a single wallet, display the single master public key. + walletMasterKey.setText(mpks[0].toString()) + rgCosigners.setVisibility(View.GONE) + } + } else { + // Imported wallets do not have a master public key. + tvMasterPublicKey.setVisibility(View.GONE) + walletMasterKey.setVisibility(View.GONE) + // Using View.INVISIBLE on the 'Copy' button to preserve layout. + (fabCopyMasterKey2 as View).setVisibility(View.INVISIBLE) + } + } +} 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..2cfe6f45753f 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/NewWallet.kt @@ -1,16 +1,22 @@ package org.electroncash.electroncash3 import android.app.Dialog -import android.content.Intent +import android.content.* import android.os.Bundle import android.text.Selection import android.view.View +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 +25,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 +39,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 +48,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: DialogFragment = 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,6 +73,15 @@ 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) { if (name.isEmpty()) { @@ -94,34 +111,168 @@ 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.keystore) + .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) + + /* Choose the appropriate keystore dropdown, based on wallet type */ + val numOfCosigners = arguments!!.getInt("cosigners") + val keystores = arguments!!.getStringArrayList("keystores") + + /* Handle dialog title for cosigners */ + if (keystores != null) { + setMultsigTitle(keystores.size + 1, numOfCosigners) + } + + val keystoreMenu: Int + if (keystores != null && keystores.size != 0) { + keystoreMenu = R.menu.cosigner_type + keystoreDesc.setText(R.string.add_a) + } else { + 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() } + } + } + + fun setMultsigTitle(m: Int, n: Int) { + dialog.setTitle(getString(R.string.Add_cosigner) + " " + + getString(R.string.__d_of, m, n)) + } +} -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) { + val numCosigners = arguments!!.getInt("cosigners") + builder.setTitle(getString(R.string.Add_cosigner) + " " + + getString(R.string.__d_of, keystores.size + 1, numCosigners)) + } 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 nextArguments = Bundle(arguments) + // The first cosigner sees their master public key; others are prompted for data + if (currentCosigner == 1) { + (targetFragment as DialogFragment).dismiss() + val nextDialog: DialogFragment = MasterPublicKeyDialog() + nextDialog.setArguments(nextArguments.apply { + val masterKey = result!!.callAttr("get", "xpub").toString() + putString("masterKey", masterKey) + }) + showDialog(this, nextDialog) + } + + // Update dialog title and arguments for the next cosigner + val nextCosigner = currentCosigner + 1 + val keystoreDialog = (targetFragment as KeystoreDialog) + keystoreDialog.setArguments(nextArguments.apply { + putStringArrayList("keystores", keystores) + }) + keystoreDialog.setMultsigTitle(nextCosigner, numCosigners) + } 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 +303,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")) { @@ -185,7 +339,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 +358,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) @@ -241,8 +395,17 @@ class NewWalletImportMasterDialog : NewWalletDialog2() { 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 +419,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 +466,148 @@ 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: DialogFragment = 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 { + try { + val nextDialog: DialogFragment = KeystoreDialog() + val nextArguments = Bundle(arguments) + + nextDialog.setArguments(nextArguments) + showDialog(this, nextDialog) + } catch (e: ToastException) { + e.show() + } + } + } +} 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/Send.kt b/android/app/src/main/java/org/electroncash/electroncash3/Send.kt index 616ed14e78fc..c4f68c7b3991 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Send.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Send.kt @@ -15,10 +15,12 @@ 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.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,8 +37,18 @@ class SendDialog : TaskLauncherDialog() { } val model: Model by viewModels() + // The "unbroadcasted" flag controls whether the dialog opens as "Send" (false) or + // "Save" (true). If the dialog type has been explicitly set via an argument (e.g. from + // Main.kt), use it; otherwise, non-multisig and 1-of-n multisig wallets will open the dialog + // as "Send", and other m-of-n multisig wallets will open it as "Save". 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 + } } lateinit var amountBox: AmountBox var settingAmount = false // Prevent infinite recursion. @@ -119,6 +131,13 @@ class SendDialog : TaskLauncherDialog() { } setFeeLabel() + // Check if a transaction hex string has been passed from ColdLoad, and load it. + val txHex = arguments?.getString("txHex") + if (txHex != null) { + val tx = libTransaction.callAttr("Transaction", txHex) + setLoadedTransaction(tx) + } + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { onOK() } dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { scanQR(this) } model.tx.observe(this, Observer { onTx(it) }) @@ -139,8 +158,11 @@ 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 loading a transaction from ColdLoad, it does not need to be constantly refreshed. + if (arguments?.containsKey("txHex") != true) { + model.tx.refresh(TxArgs(wallet, model.paymentRequest, etAddress.text.toString(), + amountBox.amount, btnMax.isChecked)) + } } fun onTx(result: TxResult) { @@ -200,7 +222,7 @@ class SendDialog : TaskLauncherDialog() { 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")) @@ -275,8 +297,52 @@ class SendDialog : TaskLauncherDialog() { } } + /** + * Fill in the Send dialog with data from a loaded transaction. + */ + fun setLoadedTransaction(tx: PyObject) { + dialog.setTitle(R.string.sign_transaction) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setText(R.string.sign) + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setEnabled(false) + btnContacts.setImageResource(R.drawable.ic_check_24dp) + + btnContacts.isEnabled = false + amountBox.isEditable = false + btnMax.isEnabled = false + + val txInfo = daemonModel.wallet!!.callAttr("get_tx_info", tx) + + val fee: Int = txInfo["fee"]!!.toInt() / tx.callAttr("estimated_size").toInt() + sbFee.progress = fee - 1 + sbFee.isEnabled = false + setFeeLabel(tx) + + // Get the list of transaction outputs, add every non-related address to the + // "recipients" array, and add up the total amount that is being sent. + val outputs = tx.callAttr("outputs").asList() + var amount: Long = 0 + val recipients: ArrayList = ArrayList() + for (output in outputs) { + val address = output.asList()[1] + if (!daemonModel.wallet!!.callAttr("is_mine", address).toBoolean()) { + amount += output.asList()[2].toLong() + recipients.add(address.toString()) + } + } + + // 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]) + } else { + etAddress.setText(R.string.pay_to_many) + } + etAddress.isFocusable = false + setAmount(amount) + } + fun onOK() { - if (model.tx.isComplete()) { + if (arguments?.containsKey("txHex") == true || model.tx.isComplete()) { onPostExecute(Unit) } else { launchTask() @@ -289,11 +355,19 @@ class SendDialog : TaskLauncherDialog() { 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. + // If a transaction has been passed from ColdLoad, it will be used. + // Otherwise, the transaction is built from the fields in the Send dialog. + val txHex = arguments?.getString("txHex") + if (txHex == null) { + 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()) + if (txHex != null) { + putString("txHex", txHex) + } }}) } catch (e: ToastException) { e.show() } } @@ -355,7 +429,13 @@ 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 { + if (arguments?.containsKey("txHex") == true) { + libTransaction.callAttr("Transaction", arguments!!.getString("txHex")) + } else { + sendDialog.model.tx.value!!.get() + } + } override fun onPassword(password: String) { val wallet = sendDialog.wallet @@ -385,6 +465,18 @@ class SendPasswordDialog : PasswordDialog() { } else { copyToClipboard(tx.toString(), R.string.signed_transaction) } + + // The presence of "txHex" argument means that this dialog had been called from ColdLoad. + // If the transaction cannot be broadcasted after signing, close the ColdLoad dialog. + // Otherwise, put the fully signed string into ColdLoad, making it available for sending. + if (arguments!!.containsKey("txHex")) { + val coldLoadDialog: ColdLoadDialog? = findDialog(activity!!, ColdLoadDialog::class) + if (!canBroadcast(tx)) { + coldLoadDialog!!.dismiss() + } else { + coldLoadDialog!!.etTransaction.setText(tx.toString()) + } + } } } 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..1076aeb2efc7 100644 --- a/android/app/src/main/java/org/electroncash/electroncash3/Util.kt +++ b/android/app/src/main/java/org/electroncash/electroncash3/Util.kt @@ -250,3 +250,19 @@ private fun menuToList(menu: Menu): List { } return result } + +/** + * Interface to base_encode/decode in bitcoin.py + */ +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 9cbd216be92c..b6220ae4c728 100644 --- a/android/app/src/main/python/electroncash_gui/android/strings.py +++ b/android/app/src/main/python/electroncash_gui/android/strings.py @@ -11,6 +11,7 @@ from gettext import gettext as _, ngettext ngettext("%d address", "%d addresses", 1) +_("(%d of %d)") _("Are you sure you want to delete your wallet \'%s\'?") _("BIP39 seed") _("Block explorer") @@ -22,6 +23,7 @@ _("Console") _("Copyright © 2017-2021 Electron Cash LLC and the Electron Cash developers.") _("Current password") +_("Cosigner %d") _("Delete wallet") _("Derivation invalid") _("Disconnect") @@ -36,6 +38,8 @@ _("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.") @@ -47,6 +51,7 @@ _("Request") _("Restore from seed") _("Save transaction") +_("Sign transaction") _("Show seed") _("Size") _("Signed transaction") @@ -55,6 +60,7 @@ _("%1$d tx (%2$d unverified)") _("Type, paste, or scan a valid signed transaction in hex format below:") _("Use a master key") +_("Wallet information") _("Wallet name is too long") _("Wallet names cannot contain the '/' character. Please enter a different wallet name to proceed.") _("Wallet exported successfully") diff --git a/android/app/src/main/res/layout/choose_keystore.xml b/android/app/src/main/res/layout/choose_keystore.xml new file mode 100644 index 000000000000..23a02fb62c2d --- /dev/null +++ b/android/app/src/main/res/layout/choose_keystore.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ 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..44fee980b73a 100644 --- a/android/app/src/main/res/layout/load.xml +++ b/android/app/src/main/res/layout/load.xml @@ -29,14 +29,15 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="4dp" - android:layout_marginRight="4dp" android:inputType="textNoSuggestions" android:minLines="3" app:layout_constraintEnd_toStartOf="@+id/btnPaste" + app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="@+id/textView4" app:layout_constraintTop_toBottomOf="@+id/textView4" tools:text="@string/test_address"> - + + @@ -48,8 +49,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/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/wallet_information.xml b/android/app/src/main/res/layout/wallet_information.xml new file mode 100644 index 000000000000..017d90faa056 --- /dev/null +++ b/android/app/src/main/res/layout/wallet_information.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..5c2e99c2cf8a 100644 --- a/android/app/src/main/res/menu/wallet.xml +++ b/android/app/src/main/res/menu/wallet.xml @@ -18,6 +18,9 @@ android:id="@+id/menuShowSeed" android:title="@string/show_seed"/> + 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 @@ - -