Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tx execution with non-owners; adjust ledger signing flow for execution #2013

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/java/io/gnosis/safe/HeimdallApplication.kt
Original file line number Diff line number Diff line change
@@ -103,6 +103,9 @@ class HeimdallApplication : MultiDexApplication(), ComponentProvider {
}

companion object Companion {

const val LEDGER_EXECUTION = true

operator fun get(context: Context): ApplicationComponent {
return (context.applicationContext as ComponentProvider).get()
}
Original file line number Diff line number Diff line change
@@ -17,15 +17,16 @@ import android.os.Build
import android.os.ParcelUuid
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.chunkDataAPDU
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.commandGetAddress
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.commandSignMessage
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.commandSignTx
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.parseGetAddress
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.parseSignMessage
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.splitPath
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.unwrapAPDU
import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper.wrapAPDU
import io.gnosis.safe.ui.settings.owner.ledger.ble.ConnectionEventListener
import io.gnosis.safe.ui.settings.owner.ledger.ble.ConnectionManager
import io.gnosis.safe.ui.settings.owner.ledger.transport.LedgerException
import io.gnosis.safe.ui.settings.owner.ledger.transport.SerializeHelper
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
@@ -34,12 +35,11 @@ import kotlinx.coroutines.withTimeout
import pm.gnosis.crypto.utils.asEthereumAddressChecksumString
import pm.gnosis.model.Solidity
import pm.gnosis.utils.asEthereumAddress
import pm.gnosis.utils.hexToByteArray
import pm.gnosis.utils.nullOnThrow
import pm.gnosis.utils.toHexString
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.util.*
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import kotlin.coroutines.Continuation
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@@ -59,12 +59,14 @@ class LedgerController(val context: Context) {
private set

private var deviceConnectedCallback: DeviceConnectedCallback? = null
private var addressContinuations: Queue<Continuation<Solidity.Address>> = LinkedList<Continuation<Solidity.Address>>()
private var signContinuations: Queue<Continuation<String>> = LinkedList<Continuation<String>>()
private var addressContinuations: Queue<Continuation<Solidity.Address>> = LinkedList()
private var signContinuations: Queue<Continuation<String>> = LinkedList()

var writeCharacteristic: BluetoothGattCharacteristic? = null
var notifyCharacteristic: BluetoothGattCharacteristic? = null

private var mtu: Int = 20

private fun loadDeviceCharacteristics() {
val characteristic = connectedDevice?.let {
ConnectionManager.servicesOnDevice(it)?.flatMap { service ->
@@ -88,11 +90,16 @@ class LedgerController(val context: Context) {
}

onDisconnect = {
Timber.d("onDisconnect()")
}

onCharacteristicRead = { _, characteristic -> }
onCharacteristicRead = { _, characteristic ->
Timber.d("onCharacteristicRead()")
}

onCharacteristicWrite = { _, characteristic -> }
onCharacteristicWrite = { _, characteristic ->
Timber.d("onCharacteristicWrite()")
}

onCharacteristicWriteError = { _, _, error ->
val addressContinuation = nullOnThrow {
@@ -103,7 +110,9 @@ class LedgerController(val context: Context) {
}
}

onMtuChanged = { _, mtu -> }
onMtuChanged = { _, mtu ->
this@LedgerController.mtu = mtu
}

onCharacteristicChanged = { _, characteristic ->

@@ -190,8 +199,8 @@ class LedgerController(val context: Context) {
}

private fun locationPermissionMissing() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.S
&& (!context.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION))
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.S
&& (!context.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION))

private fun blePermissionMissing() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& (!context.hasPermission(Manifest.permission.BLUETOOTH_SCAN) || !context.hasPermission(Manifest.permission.BLUETOOTH_CONNECT))
@@ -221,74 +230,22 @@ class LedgerController(val context: Context) {
ConnectionManager.unregisterListener(connectionEventListener)
}

fun getSignCommand(path: String, message: String): ByteArray {

val paths = splitPath(path)
val messageBytes = message.hexToByteArray()

val pathsData = ByteArray(paths.size)
paths.forEachIndexed { index, element ->
pathsData[index] = element
suspend fun getTxSignature(path: String, encodedTx: String): String = suspendCoroutine { continuation ->
val payload = commandSignTx(path, encodedTx)
val chunks = chunkDataAPDU(payload, 150)
chunks.forEach {
ConnectionManager.writeCharacteristic(connectedDevice!!, writeCharacteristic!!, it)
}

val commandData = mutableListOf<Byte>()
commandData.add(0xe0.toByte())
commandData.add(0x08.toByte())
commandData.add(0x00.toByte())
commandData.add(0x00.toByte())

val messageData = ByteArrayOutputStream()
SerializeHelper.writeUint32BE(messageData, messageBytes.size.toLong())
messageBytes.forEachIndexed { index, element ->
messageData.write(element.toInt())
}

commandData.add((paths.size + messageBytes.size + 4).toByte())
commandData.addAll(pathsData.toList())
commandData.addAll(messageData.toByteArray().toList())

// Command length should be 150 bytes length otherwise we should split
// it into chuncks. As we sign hashes we should be fine for now.
val command = commandData.toByteArray()
Timber.d("Sign command: ${command.toHexString()}")

if (command.size > 150) throw LedgerException(LedgerException.ExceptionReason.IO_ERROR, "invalid data format")

return command
signContinuations.add(continuation)
}

suspend fun getSignature(path: String, message: String): String = suspendCoroutine { continuation ->
ConnectionManager.writeCharacteristic(connectedDevice!!, writeCharacteristic!!, wrapAPDU(getSignCommand(path, message)))
ConnectionManager.writeCharacteristic(connectedDevice!!, writeCharacteristic!!, wrapAPDU(commandSignMessage(path, message)))
signContinuations.add(continuation)
}

fun getAddressCommand(path: String, displayVerificationDialog: Boolean = false, chainCode: Boolean = false): ByteArray {

val paths = splitPath(path)

val commandData = mutableListOf<Byte>()

val pathsData = ByteArray(1 + paths.size)
pathsData[0] = paths.size.toByte()

paths.forEachIndexed { index, element ->
pathsData[1 + index] = element
}

commandData.add(0xe0.toByte())
commandData.add(0x02.toByte())
commandData.add((if (displayVerificationDialog) 0x01.toByte() else 0x00.toByte()))
commandData.add((if (chainCode) 0x01.toByte() else 0x00.toByte()))
commandData.addAll(pathsData.toList())

val command = commandData.toByteArray()
Timber.d("Get address command: ${command.toHexString()}")

return command
}

private suspend fun getAddress(device: BluetoothDevice, path: String): Solidity.Address = suspendCancellableCoroutine { continuation ->
ConnectionManager.writeCharacteristic(device, writeCharacteristic!!, wrapAPDU(getAddressCommand(path)))
ConnectionManager.writeCharacteristic(device, writeCharacteristic!!, wrapAPDU(commandGetAddress(path)))
addressContinuations.add(continuation)
}

@@ -328,7 +285,6 @@ class LedgerController(val context: Context) {
fragment.requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), REQUEST_CODE_BLE_PERMISSION)
} else {
fragment.requestPermissions(arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT), REQUEST_CODE_BLE_PERMISSION)

}
}

Original file line number Diff line number Diff line change
@@ -31,13 +31,14 @@ class LedgerDeviceListFragment : BaseViewBindingFragment<FragmentLedgerDeviceLis
enum class Mode {
ADDRESS_SELECTION,
CONFIRMATION,
REJECTION
REJECTION,
EXECUTION
}

private val navArgs by navArgs<LedgerDeviceListFragmentArgs>()
private val mode by lazy { Mode.valueOf(navArgs.mode) }
private val owner by lazy { navArgs.owner }
private val safeTxHash by lazy { navArgs.safeTxHash }
private val txHash by lazy { navArgs.txHash }

override fun screenId() = ScreenId.LEDGER_DEVICE_LIST

@@ -109,15 +110,24 @@ class LedgerDeviceListFragment : BaseViewBindingFragment<FragmentLedgerDeviceLis
findNavController().navigate(
LedgerDeviceListFragmentDirections.actionLedgerDeviceListFragmentToLedgerSignDialog(
owner!!,
safeTxHash!!
txHash!!,
Mode.CONFIRMATION.name
)
)
Mode.REJECTION ->
findNavController().navigate(
LedgerDeviceListFragmentDirections.actionLedgerDeviceListFragmentToLedgerSignDialog(
owner!!,
safeTxHash!!,
false
txHash!!,
Mode.REJECTION.name
)
)
Mode.EXECUTION ->
findNavController().navigate(
LedgerDeviceListFragmentDirections.actionLedgerDeviceListFragmentToLedgerSignDialog(
owner!!,
txHash!!,
Mode.EXECUTION.name
)
)
}
Original file line number Diff line number Diff line change
@@ -20,8 +20,14 @@ import javax.inject.Inject

class LedgerSignDialog : BaseBottomSheetDialogFragment<DialogLedgerSignBinding>() {

enum class Mode {
CONFIRMATION,
REJECTION,
EXECUTION
}

private val navArgs by navArgs<LedgerSignDialogArgs>()
private val confirmation by lazy { navArgs.confirmation }
private val mode by lazy { Mode.valueOf(navArgs.mode) }
private val owner by lazy { navArgs.owner.asEthereumAddress()!! }
private val safeTxHash by lazy { navArgs.safeTxHash }

@@ -43,7 +49,11 @@ class LedgerSignDialog : BaseBottomSheetDialogFragment<DialogLedgerSignBinding>(
super.onViewCreated(view, savedInstanceState)

with(binding) {
actionLabel.text = getString(if (confirmation) R.string.ledger_sign_confirm else R.string.ledger_sign_reject)
actionLabel.text = getString(when(mode) {
Mode.CONFIRMATION -> R.string.ledger_sign_confirm
Mode.REJECTION -> R.string.ledger_sign_reject
Mode.EXECUTION -> R.string.ledger_sign_execute
})
hash.text = viewModel.getPreviewHash(safeTxHash)
cancel.setOnClickListener {
navigateBack()
@@ -65,7 +75,7 @@ class LedgerSignDialog : BaseBottomSheetDialogFragment<DialogLedgerSignBinding>(
}
})

viewModel.getSignature(owner, safeTxHash)
viewModel.getSignature(mode, owner, safeTxHash)
}

override fun onStop() {
@@ -74,29 +84,45 @@ class LedgerSignDialog : BaseBottomSheetDialogFragment<DialogLedgerSignBinding>(
}

private fun navigateBack(signedSafeTxHash: String? = null) {
if (confirmation) {
findNavController().popBackStack(R.id.signingOwnerSelectionFragment, true)
signedSafeTxHash?.let {
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SELECTED_RESULT,
owner.asEthereumAddressString()
)
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SIGNED_RESULT,
it
)
when(mode) {
Mode.CONFIRMATION -> {
findNavController().popBackStack(R.id.signingOwnerSelectionFragment, true)
signedSafeTxHash?.let {
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SELECTED_RESULT,
owner.asEthereumAddressString()
)
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SIGNED_RESULT,
it
)
}
}
} else {
findNavController().popBackStack(R.id.signingOwnerSelectionFragment, true)
signedSafeTxHash?.let {
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SELECTED_RESULT,
owner.asEthereumAddressString()
)
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SIGNED_RESULT,
it
)
Mode.REJECTION -> {
findNavController().popBackStack(R.id.signingOwnerSelectionFragment, true)
signedSafeTxHash?.let {
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SELECTED_RESULT,
owner.asEthereumAddressString()
)
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SIGNED_RESULT,
it
)
}
}
Mode.EXECUTION -> {
findNavController().popBackStack(R.id.ledgerDeviceListFragment, true)
signedSafeTxHash?.let {
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SELECTED_RESULT,
owner.asEthereumAddressString()
)
findNavController().currentBackStackEntry?.savedStateHandle?.set(
SafeOverviewBaseFragment.OWNER_SIGNED_RESULT,
it
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -17,13 +17,23 @@ class LedgerSignViewModel

override fun initialState() = LedgerSignState(ViewAction.Loading(true))

fun getSignature(ownerAddress: Solidity.Address, safeTxHash: String) {
fun getSignature(mode: LedgerSignDialog.Mode, ownerAddress: Solidity.Address, txHash: String) {
safeLaunch {
val owner = credentialsRepository.owner(ownerAddress)!!
val signature = ledgerController.getSignature(
owner.keyDerivationPath!!,
safeTxHash
)
val signature = when (mode) {
LedgerSignDialog.Mode.EXECUTION -> {
ledgerController.getTxSignature(
owner.keyDerivationPath!!,
txHash
)
}
else -> {
ledgerController.getSignature(
owner.keyDerivationPath!!,
txHash
)
}
}
updateState {
LedgerSignState(Signature(signature))
}
@@ -37,7 +47,7 @@ class LedgerSignViewModel
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(safeTxHash.hexToByteArray())
val sha256hash = digest.fold("", { str, it -> str + "%02x".format(it) })
return sha256hash.toUpperCase()
return sha256hash.uppercase()
}

fun disconnectFromDevice() {
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package io.gnosis.safe.ui.settings.owner.ledger.ble

import android.bluetooth.*
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.TRANSPORT_LE
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -14,7 +20,7 @@ import pm.gnosis.utils.toHex
import pm.gnosis.utils.toHexString
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue

@@ -422,26 +428,18 @@ object ConnectionManager {
}
BluetoothGatt.GATT_WRITE_NOT_PERMITTED -> {
Timber.e("Write not permitted for $uuid!")

//FIXME: define custom operation for GetAddress command
if (pendingOperation is CharacteristicWrite) {
signalEndOfOperation()
}

throw LedgerException(LedgerException.ExceptionReason.IO_ERROR, "Write not permitted for $uuid!")

}
else -> {
Timber.e("Characteristic write failed for $uuid, error: $status")
val error = LedgerException(LedgerException.ExceptionReason.IO_ERROR, "Characteristic write failed for $uuid, error: $status")
listeners.forEach { it.get()?.onCharacteristicWriteError?.invoke(gatt.device, this, error) }

//FIXME: define custom operation for GetAddress command
if (pendingOperation is CharacteristicWrite) {
signalEndOfOperation()
}
}
}
//FIXME: define custom operation for GetAddress command
if (pendingOperation is CharacteristicWrite) {
signalEndOfOperation()
}
}
}

Original file line number Diff line number Diff line change
@@ -3,12 +3,42 @@ package io.gnosis.safe.ui.settings.owner.ledger
import io.gnosis.safe.ui.settings.owner.ledger.transport.LedgerException
import io.gnosis.safe.ui.settings.owner.ledger.transport.SerializeHelper
import pm.gnosis.utils.asBigInteger
import pm.gnosis.utils.hexToByteArray
import pm.gnosis.utils.toHexString
import timber.log.Timber
import java.io.ByteArrayOutputStream


object LedgerWrapper {

private const val TAG_APDU = 0x05
const val TAG_APDU = 0x05

fun chunkDataAPDU(data: ByteArray, chunkSize: Int): List<ByteArray> {
var chunkPayloadStartIndex = 0
var chunkPayloadEndIndex = 0
var chunk = 0
val chunks = mutableListOf<ByteArray>()
while (chunkPayloadEndIndex < data.size) {
chunkPayloadEndIndex = if (chunkPayloadStartIndex + chunkSize >= data.size) data.size else chunkPayloadStartIndex + chunkSize
if (chunk == 0) {
chunkPayloadEndIndex -= 5
} else if (chunkPayloadEndIndex - chunkPayloadStartIndex == chunkSize) {
chunkPayloadEndIndex -= 3
}
val chunkBytes = ByteArrayOutputStream()
chunkBytes.write(TAG_APDU)
SerializeHelper.writeUint16BE(chunkBytes, chunk.toLong())
if (chunk == 0) {
SerializeHelper.writeUint16BE(chunkBytes, data.count().toLong())
}
chunkBytes.write(data, chunkPayloadStartIndex, chunkPayloadEndIndex - chunkPayloadStartIndex)

chunks.add(chunkBytes.toByteArray())
chunk += 1
chunkPayloadStartIndex = chunkPayloadEndIndex
}
return chunks
}

fun wrapAPDU(data: ByteArray): ByteArray {
val apdu = ByteArrayOutputStream()
@@ -75,4 +105,87 @@ object LedgerWrapper {
s.toString(16).padStart(64, '0').substring(0, 64) +
v.toString(16).padStart(2, '0')
}

fun commandSignTx(path: String, encodedTx: String): ByteArray {

val pathsData = splitPath(path)
val txBytes = encodedTx.hexToByteArray()

val commandData = mutableListOf<Byte>()
commandData.add(0xe0.toByte())
commandData.add(0x04.toByte())
commandData.add(0x00.toByte())
commandData.add(0x00.toByte())

val txData = ByteArrayOutputStream()
SerializeHelper.writeUint32BE(txData, txBytes.size.toLong())
txBytes.forEachIndexed { index, element ->
txData.write(element.toInt())
}

commandData.add((pathsData.size + txBytes.size + 4).toByte())
commandData.addAll(pathsData.toList())
commandData.addAll(txData.toByteArray().toList())

val command = commandData.toByteArray()
Timber.d("Sign tx command: ${command.toHexString()}")

return command
}

fun commandSignMessage(path: String, message: String): ByteArray {

val pathsData = splitPath(path)
val messageBytes = message.hexToByteArray()

val commandData = mutableListOf<Byte>()
commandData.add(0xe0.toByte())
commandData.add(0x08.toByte())
commandData.add(0x00.toByte())
commandData.add(0x00.toByte())

val messageData = ByteArrayOutputStream()
SerializeHelper.writeUint32BE(messageData, messageBytes.size.toLong())
messageBytes.forEachIndexed { index, element ->
messageData.write(element.toInt())
}

commandData.add((pathsData.size + messageBytes.size + 4).toByte())
commandData.addAll(pathsData.toList())
commandData.addAll(messageData.toByteArray().toList())

// Command length should be 150 bytes length otherwise we should split
// it into chuncks. As we sign hashes we should be fine for now.
val command = commandData.toByteArray()
Timber.d("Sign command: ${command.toHexString()}")

if (command.size > 150) throw LedgerException(LedgerException.ExceptionReason.IO_ERROR, "invalid data format")

return command
}

fun commandGetAddress(path: String, displayVerificationDialog: Boolean = false, chainCode: Boolean = false): ByteArray {

val paths = splitPath(path)

val commandData = mutableListOf<Byte>()

val pathsData = ByteArray(1 + paths.size)
pathsData[0] = paths.size.toByte()

paths.forEachIndexed { index, element ->
pathsData[1 + index] = element
}

commandData.add(0xe0.toByte())
commandData.add(0x02.toByte())
commandData.add((if (displayVerificationDialog) 0x01.toByte() else 0x00.toByte()))
commandData.add((if (chainCode) 0x01.toByte() else 0x00.toByte()))
commandData.addAll(pathsData.toList())

val command = commandData.toByteArray()
Timber.d("Get address command: ${command.toHexString()}")

return command
}
}
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import io.gnosis.data.models.Chain
import io.gnosis.data.models.Owner
import io.gnosis.data.repositories.CredentialsRepository
import io.gnosis.data.repositories.SafeRepository
import io.gnosis.safe.HeimdallApplication.Companion.LEDGER_EXECUTION
import io.gnosis.safe.ui.base.AppDispatchers
import io.gnosis.safe.ui.base.BaseStateViewModel
import io.gnosis.safe.ui.settings.app.SettingsHandler
@@ -69,9 +70,11 @@ class OwnerListViewModel
.map { OwnerViewData(it.address, it.name, it.type) }
.sortedBy { it.name }
val acceptedOwners = owners.filter { localOwner ->
safe.signingOwners.any {
//TODO: Modify this check when we have tx execution on Ledger Nano X
localOwner.address == it && localOwner.type != Owner.Type.LEDGER_NANO_X
//TODO: [Ledger execution] remove filter after successfully tested
if (LEDGER_EXECUTION) {
true
} else {
localOwner.type != Owner.Type.LEDGER_NANO_X
}
}
val balances = rpcClient.getBalances(acceptedOwners.map { it.address })
@@ -113,7 +116,7 @@ class OwnerListViewModel
updateState {
OwnerListState(
ViewAction.NavigateTo(
SigningOwnerSelectionFragmentDirections.actionSigningOwnerSelectionFragmentToLedgerDeviceListFragmet(
SigningOwnerSelectionFragmentDirections.actionSigningOwnerSelectionFragmentToLedgerDeviceListFragment(
if (isConfirmation) LedgerDeviceListFragment.Mode.CONFIRMATION.name else LedgerDeviceListFragment.Mode.REJECTION.name,
owner.asEthereumAddressString(),
safeTxHash
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ import io.gnosis.data.repositories.TransactionRepository
import io.gnosis.data.utils.SemVer
import io.gnosis.data.utils.calculateSafeTxHash
import io.gnosis.data.utils.toSignatureString
import io.gnosis.safe.HeimdallApplication.Companion.LEDGER_EXECUTION
import io.gnosis.safe.Tracker
import io.gnosis.safe.ui.base.AppDispatchers
import io.gnosis.safe.ui.base.BaseStateViewModel
@@ -136,8 +137,14 @@ class TransactionDetailsViewModel
executionInfo: DetailedExecutionInfo.MultisigExecutionDetails,
localOwners: List<Owner>
): Boolean {
//TODO: Modify this check when we have tx execution on Ledger Nano X
val ownersThatCanExecute = localOwners.filter { it.type != Owner.Type.LEDGER_NANO_X && executionInfo.signers.contains(AddressInfo(it.address))}
val ownersThatCanExecute = localOwners.filter {
//TODO: [Ledger execution] remove filter after successfully tested
if (LEDGER_EXECUTION) {
true
} else {
it.type != Owner.Type.LEDGER_NANO_X
}
}
return ownersThatCanExecute.isNotEmpty() && executionInfo.confirmations.size >= executionInfo.confirmationsRequired
}

Original file line number Diff line number Diff line change
@@ -8,15 +8,17 @@ import io.gnosis.data.models.Safe
import io.gnosis.data.models.transaction.DetailedExecutionInfo
import io.gnosis.data.models.transaction.TxData
import io.gnosis.data.repositories.CredentialsRepository
import io.gnosis.data.repositories.TransactionLocalRepository
import io.gnosis.data.repositories.SafeRepository
import io.gnosis.data.repositories.TransactionLocalRepository
import io.gnosis.data.utils.toSignature
import io.gnosis.safe.HeimdallApplication.Companion.LEDGER_EXECUTION
import io.gnosis.safe.Tracker
import io.gnosis.safe.TxExecField
import io.gnosis.safe.ui.base.AppDispatchers
import io.gnosis.safe.ui.base.BaseStateViewModel
import io.gnosis.safe.ui.base.BaseStateViewModel.ViewAction.Loading
import io.gnosis.safe.ui.settings.app.SettingsHandler
import io.gnosis.safe.ui.settings.owner.ledger.LedgerDeviceListFragment
import io.gnosis.safe.ui.settings.owner.list.OwnerViewData
import io.gnosis.safe.ui.transactions.details.SigningMode
import io.gnosis.safe.utils.BalanceFormatter
@@ -114,19 +116,18 @@ class TxReviewViewModel
val owners = credentialsRepository.owners().map { OwnerViewData(it.address, it.name, it.type) }
activeSafe.signingOwners?.let {
val acceptedOwners = owners.filter { localOwner ->
activeSafe.signingOwners.any {
localOwner.address == it
//TODO: [Ledger execution] remove filter after successfully tested
if (LEDGER_EXECUTION) {
true
} else {
localOwner.type != Owner.Type.LEDGER_NANO_X
}
}
// select owner with highest balance
// get default execution key
kotlin.runCatching {
rpcClient.getBalances(acceptedOwners.map { it.address })
}.onSuccess {
executionKey = acceptedOwners
// TODO: Remove this filter when Ledger tx execution is implemented
.filter {
it.type != Owner.Type.LEDGER_NANO_X
}
val executionKeys = acceptedOwners
.mapIndexed { index, owner ->
owner to it[index]
}
@@ -138,7 +139,13 @@ class TxReviewViewModel
zeroBalance = it.second?.value == BigInteger.ZERO
)
}
.first()

// get safe owner key with highest balance or non-owner with highest balance if not available
executionKey = executionKeys.firstOrNull { localOwner ->
activeSafe.signingOwners.any {
localOwner.address == it
}
} ?: executionKeys.first()

updateState {
TxReviewState(viewAction = DefaultKey(key = executionKey))
@@ -363,7 +370,7 @@ class TxReviewViewModel
return when (ethTx) {
is Transaction.Eip1559 -> {
val ethTxEip1559 = ethTx as Transaction.Eip1559
if (ownerType == Owner.Type.KEYSTONE) {
if (ownerType == Owner.Type.KEYSTONE || ownerType == Owner.Type.LEDGER_NANO_X) {
byteArrayOf(ethTxEip1559.type, *ethTxEip1559.rlp())
} else {
ethTxEip1559.hash()
@@ -410,7 +417,22 @@ class TxReviewViewModel
}

Owner.Type.LEDGER_NANO_X -> {

updateState {
TxReviewState(
viewAction = ViewAction.NavigateTo(
TxReviewFragmentDirections.actionTxReviewFragmentToLedgerDeviceListFragment(
mode = LedgerDeviceListFragment.Mode.EXECUTION.name,
owner = it.address.asEthereumAddressString(),
txHash = ethTxHash.toHexString()
)
)
)
}
updateState {
TxReviewState(
viewAction = ViewAction.None
)
}
}

Owner.Type.KEYSTONE -> {
@@ -442,7 +464,6 @@ class TxReviewViewModel
ethTxSignature = signatueString.toSignature()
if (ethTxSignature!!.v <= 1) {
ethTxSignature!!.v = (ethTxSignature!!.v + 27).toByte()

}
}

14 changes: 9 additions & 5 deletions app/src/main/res/navigation/main_nav.xml
Original file line number Diff line number Diff line change
@@ -862,7 +862,7 @@
app:popUpTo="@id/signingOwnerSelectionFragment" />

<action
android:id="@+id/action_signingOwnerSelectionFragment_to_ledgerDeviceListFragmet"
android:id="@+id/action_signingOwnerSelectionFragment_to_ledgerDeviceListFragment"
app:destination="@id/ledgerDeviceListFragment"
app:popUpTo="@id/signingOwnerSelectionFragment" />

@@ -1306,6 +1306,11 @@
app:destination="@id/keystoneRequestSignatureFragment"
app:popUpTo="@id/txReviewFragment" />

<action
android:id="@+id/action_txReviewFragment_to_ledgerDeviceListFragment"
app:destination="@id/ledgerDeviceListFragment"
app:popUpTo="@id/txReviewFragment" />

<action
android:id="@+id/action_txReviewFragment_to_txSuccessFragment"
app:destination="@id/txSuccessFragment" />
@@ -1428,7 +1433,7 @@
app:nullable="true" />

<argument
android:name="safeTxHash"
android:name="txHash"
android:defaultValue="@null"
app:argType="string"
app:nullable="true" />
@@ -1450,9 +1455,8 @@
app:argType="string" />

<argument
android:name="confirmation"
android:defaultValue="true"
app:argType="boolean" />
android:name="mode"
app:argType="string" />

</dialog>

1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -679,6 +679,7 @@

<string name="ledger_sign_confirm">Confirm Transaction</string>
<string name="ledger_sign_reject">Reject Transaction</string>
<string name="ledger_sign_execute">Execute Transaction</string>
<string name="ledger_sign_verify">Verify the transaction hash below on your Ledger Nano X and confirm by pressing both buttons simultaneously.</string>
<string name="ledger_sign_cancel">Cancel</string>

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package io.gnosis.safe.ui.settings.owner.ledger.transport

import io.gnosis.safe.ui.settings.owner.ledger.LedgerWrapper
import org.junit.Assert.assertEquals
import org.junit.Test
import pm.gnosis.utils.hexToByteArray
import pm.gnosis.utils.toHexString
import java.io.ByteArrayOutputStream

class LedgerWrapperTest {

@Test
fun commandGetAddress() {
assertEquals(
COMMAND_GET_ADDRESS,
LedgerWrapper.commandGetAddress(DERIVATION_PATH).toHexString()
)
}

@Test
fun commandSignMessage() {
val txHash = "0xb3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67"
val signMessageCommand = LedgerWrapper.commandSignMessage(DERIVATION_PATH, txHash)
assertEquals(
COMMAND_SIGN_MESSAGE,
signMessageCommand.toHexString()
)
}

@Test
fun wrapAPDU() {
assertEquals(
COMMAND_GET_ADDRESS_WRAPPED,
LedgerWrapper.wrapAPDU(COMMAND_GET_ADDRESS.hexToByteArray()).toHexString()
)
}

@Test
fun unwrapAPDU() {
assertEquals(
COMMAND_GET_ADDRESS,
LedgerWrapper.unwrapAPDU(COMMAND_GET_ADDRESS_WRAPPED.hexToByteArray()).toHexString()
)
}

@Test
fun chunkDataAPDU() {
val chunks = LedgerWrapper.chunkDataAPDU(COMMAND_SIGN_MESSAGE.hexToByteArray(), 20)
assertEquals(4, chunks.size)
val command = ByteArrayOutputStream()
chunks.forEachIndexed { index, chunk ->
assert(chunk.first() == LedgerWrapper.TAG_APDU.toByte())
command.write(chunk.slice((if (index == 0) 5 else 3) until chunk.size).toByteArray())
}
assertEquals(
COMMAND_SIGN_MESSAGE,
command.toByteArray().toHexString()
)
}

companion object {
const val DERIVATION_PATH = "44'/60'/0'/14"
const val COMMAND_GET_ADDRESS = "e002000011048000002c8000003c800000000000000e"
const val COMMAND_GET_ADDRESS_WRAPPED = "0500000016e002000011048000002c8000003c800000000000000e"
const val COMMAND_SIGN_MESSAGE = "e008000035048000002c8000003c800000000000000e00000020b3bb5fe5221dd17b3fe68388c115c73db01a1528cf351f9de4ec85f7f8182a67"
}
}
Original file line number Diff line number Diff line change
@@ -720,7 +720,7 @@ class TransactionDetailsViewModelTest {
txDetails = transactionDetails.toTransactionDetailsViewData(
safes = emptyList(),
canSign = false,
canExecute = false,
canExecute = true,
nextInLine = false,
hasOwnerKey = false,
owners = listOf(owner)
Original file line number Diff line number Diff line change
@@ -89,7 +89,7 @@ class TxReviewViewModelTest {
}

@Test
fun `loadDefaultKey(success, several owner keys) should emit executionKey with highest balance`() {
fun `loadDefaultKey(success, several safe owner keys) should emit owner executionKey with highest balance`() {
coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply {
signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN))
}
@@ -127,6 +127,47 @@ class TxReviewViewModelTest {
}
}


@Test
fun `loadDefaultKey(success, several safe owner & non-owner keys) should emit owner executionKey with highest balance`() {
coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply {
signingOwners = listOf(Solidity.Address(BigInteger.ONE), Solidity.Address(BigInteger.TEN))
}
coEvery { credentialsRepository.owners() } returns listOf(TEST_SAFE_OWNER1, TEST_SAFE_OWNER2, TEST_OWNER)
coEvery { rpcClient.getBalances(listOf(TEST_SAFE_OWNER1.address, TEST_SAFE_OWNER2.address, TEST_OWNER.address)) } returns listOf(
Wei(BigInteger.ONE),
Wei(BigInteger.TEN.pow(Chain.DEFAULT_CHAIN.currency.decimals)),
Wei(BigInteger.valueOf(100L).pow(Chain.DEFAULT_CHAIN.currency.decimals))
)

viewModel = TxReviewViewModel(
safeRepository,
credentialsRepository,
localTxRepository,
settingsHandler,
rpcClient,
balanceFormatter,
tracker,
appDispatchers
)

viewModel.loadDefaultKey()

with(viewModel.state.test().values()) {
Assert.assertEquals(
DefaultKey(
OwnerViewData(
TEST_SAFE_OWNER2.address,
TEST_SAFE_OWNER2.name,
Owner.Type.IMPORTED,
"1 ${Chain.DEFAULT_CHAIN.currency.symbol}",
false
)
), this[0].viewAction
)
}
}

@Test
fun `loadDefaultKey(getBalances failure) should emit LoadBalancesFailed`() {
coEvery { safeRepository.getActiveSafe() } returns TEST_SAFE.apply {
@@ -411,5 +452,11 @@ class TxReviewViewModelTest {
"owner2",
Owner.Type.IMPORTED
)

val TEST_OWNER = Owner(
Solidity.Address(BigInteger.valueOf(100L)),
"owner3",
Owner.Type.IMPORTED
)
}
}