Skip to content

Commit f278a9d

Browse files
authored
Merge pull request #76 from onflow/add-ecreover
feat: add ecRecover
2 parents 9ab0eb7 + 9fba85c commit f278a9d

File tree

6 files changed

+213
-52
lines changed

6 files changed

+213
-52
lines changed

Android/wallet/src/androidTest/java/com/flow/wallet/wallet/EthereumWalletTests.kt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.junit.Assert
1818
import com.flow.wallet.errors.WalletError
1919
import com.google.protobuf.ByteString
2020
import wallet.core.jni.EthereumAbi
21+
import java.math.BigInteger
2122

2223
class EthereumWalletTests {
2324

@@ -167,6 +168,24 @@ class EthereumWalletTests {
167168
"f86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83",
168169
output.encoded.toByteArray().toHexString()
169170
)
171+
val expectedHash = HasherImpl.keccak256(output.encoded.toByteArray()).toHexString()
172+
assertEquals(expectedHash, output.preHash.toByteArray().toHexString())
173+
assertEquals(expectedHash, output.txId().toHexString())
174+
}
175+
176+
@Test
177+
fun walletEcRecoverReturnsExpectedAddress() = runBlocking {
178+
val storage = InMemoryStorage()
179+
val privateKey = TWPrivateKey(privateKeyHex.hexToByteArray())
180+
val key = PrivateKey(privateKey, storage)
181+
val wallet = TestWallet(key, storage)
182+
183+
val signature = "a77836f00d36b5cd16c17bb26f23cdc78db7928b8d1d1341bd3f11cc279b60a508b80e01992cb0ad9a6c2212177dd84a43535e3bf29794c1dc13d17a59c2d98c1b".hexToByteArray()
184+
val message = "Hello, Flow EVM!".toByteArray()
185+
186+
val recovered = wallet.ethRecoverAddress(signature, message)
187+
188+
assertEquals("0xe513e4f52f76c9bd3db2474e885b8e7e814ea516", recovered.lowercase())
170189
}
171190

172191
@Test
@@ -264,10 +283,27 @@ class EthereumWalletTests {
264283
}
265284

266285
private fun String.hexToByteArray(): ByteArray {
286+
var clean = removePrefix("0x")
287+
if (clean.isEmpty()) return byteArrayOf()
288+
if (clean.length % 2 != 0) {
289+
clean = "0$clean"
290+
}
291+
val lower = clean.lowercase()
292+
return ByteArray(lower.length / 2) { index ->
293+
lower.substring(index * 2, index * 2 + 2).toInt(16).toByte()
294+
}
295+
}
296+
297+
private fun String.hexToMinimalByteArray(): ByteArray {
267298
val clean = removePrefix("0x")
268-
require(clean.length % 2 == 0) { "Hex string must have even length" }
269-
return ByteArray(clean.length / 2) { index ->
270-
clean.substring(index * 2, index * 2 + 2).toInt(16).toByte()
299+
if (clean.isEmpty()) return byteArrayOf()
300+
val value = BigInteger(clean, 16)
301+
if (value == BigInteger.ZERO) return byteArrayOf()
302+
val bytes = value.toByteArray()
303+
return if (bytes.isNotEmpty() && bytes[0] == 0.toByte()) {
304+
bytes.copyOfRange(1, bytes.size)
305+
} else {
306+
bytes
271307
}
272308
}
273309

Android/wallet/src/main/java/com/flow/wallet/wallet/BaseWallet.kt

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.flow.wallet.account.Account
44
import com.flow.wallet.crypto.HasherImpl
55
import com.flow.wallet.errors.WalletError
66
import com.flow.wallet.keys.EthereumKeyProtocol
7+
import com.flow.wallet.keys.EthereumSignatureUtils
78
import com.flow.wallet.keys.KeyProtocol
89
import com.flow.wallet.security.SecurityCheckDelegate
910
import com.flow.wallet.storage.StorageProtocol
@@ -21,7 +22,9 @@ import kotlin.text.Charsets
2122
import wallet.core.java.AnySigner
2223
import wallet.core.jni.CoinType
2324
import wallet.core.jni.EthereumAbi
25+
import wallet.core.jni.PublicKey
2426
import wallet.core.jni.proto.Ethereum
27+
import java.util.Locale
2528
import org.onflow.flow.models.Account as FlowAccount
2629

2730
/**
@@ -61,6 +64,7 @@ interface Wallet {
6164
suspend fun ethSignPersonalData(data: ByteArray, index: Int = 0): ByteArray
6265
suspend fun ethSignTypedData(json: String, index: Int = 0): ByteArray
6366
suspend fun ethSignTransaction(input: Ethereum.SigningInput, index: Int = 0): Ethereum.SigningOutput
67+
suspend fun ethRecoverAddress(signature: ByteArray, message: ByteArray): String
6468
}
6569

6670
/**
@@ -326,20 +330,43 @@ abstract class BaseWallet(
326330
return ethSignDigest(digest, index)
327331
}
328332

329-
override suspend fun ethSignTransaction(input: Ethereum.SigningInput, index: Int): Ethereum.SigningOutput {
333+
override suspend fun ethSignTransaction(
334+
input: Ethereum.SigningInput,
335+
index: Int
336+
): Ethereum.SigningOutput {
330337
ensureSecurityCheck()
331338
val key = resolveEthereumKey()
332339
val privateKey = key.ethPrivateKey(index)
333340
val builder = input.toBuilder()
334341
builder.privateKey = ByteString.copyFrom(privateKey)
335-
return try {
342+
return try {
336343
AnySigner.sign(builder.build(), CoinType.ETHEREUM, Ethereum.SigningOutput.parser())
337344
} finally {
338345
builder.clearPrivateKey()
339346
privateKey.fill(0)
340347
}
341348
}
342349

350+
override suspend fun ethRecoverAddress(
351+
signature: ByteArray,
352+
message: ByteArray
353+
): String {
354+
if (signature.size != 65) {
355+
throw WalletError.InvalidEthereumSignature
356+
}
357+
val normalizedSignature = EthereumSignatureUtils.normalize(signature)
358+
val prefix = "\u0019Ethereum Signed Message:\n${message.size}".toByteArray(Charsets.UTF_8)
359+
val payload = prefix + message
360+
val digest = HasherImpl.keccak256(payload)
361+
val publicKey = runCatching {
362+
PublicKey.recover(normalizedSignature, digest)
363+
}.getOrElse {
364+
throw WalletError.InvalidEthereumSignature
365+
}
366+
val address = CoinType.ETHEREUM.deriveAddressFromPublicKey(publicKey)
367+
return address
368+
}
369+
343370
private suspend fun ensureSecurityCheck() {
344371
securityDelegate?.let {
345372
val passed = it.verify()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.flow.wallet.wallet
2+
3+
import com.flow.wallet.crypto.HasherImpl
4+
import wallet.core.jni.proto.Ethereum
5+
6+
/**
7+
* Utilities for extracting transaction hash information from Ethereum signing outputs.
8+
*/
9+
fun Ethereum.SigningOutput.txId(): ByteArray {
10+
val existing = preHash.toByteArray()
11+
if (existing.isNotEmpty()) {
12+
return existing
13+
}
14+
val computed = HasherImpl.keccak256(encoded.toByteArray())
15+
return computed
16+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// EthereumSigningOutput+TxID.swift
3+
// FlowWalletKit
4+
//
5+
6+
import Foundation
7+
import WalletCore
8+
9+
public extension EthereumSigningOutput {
10+
/// Returns the transaction hash (txid) for the signed payload.
11+
func txId() -> Data {
12+
if !preHash.isEmpty {
13+
return preHash
14+
}
15+
return Hash.keccak256(data: encoded)
16+
}
17+
18+
/// Returns the transaction hash (txid) as a hex string with 0x prefix.
19+
func txIdHex() -> String {
20+
let hash = txId()
21+
guard hash.isEmpty == false else { return "0x" }
22+
return "0x" + hash.hexString
23+
}
24+
}
25+

iOS/FlowWalletKit/Sources/Wallet/Wallet+EOA.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,37 @@ extension Wallet {
5858
return try key.ethSign(digest: digest, index: index)
5959
}
6060

61+
/// Recovers the Ethereum address from a personal-sign style signature.
62+
static public func ethRecoverAddress(signature: Data, message: Data) throws -> String {
63+
let normalizedSignature = try normalizeEthereumSignature(signature)
64+
let prefixString = "\u{19}Ethereum Signed Message:\n\(message.count)"
65+
guard let prefix = prefixString.data(using: .utf8) else {
66+
throw FWKError.invalidEthereumMessage
67+
}
68+
var payload = Data()
69+
payload.append(prefix)
70+
payload.append(message)
71+
let digest = Hash.keccak256(data: payload)
72+
guard let publicKey = PublicKey.recover(signature: normalizedSignature, message: digest) else {
73+
throw FWKError.invalidEthereumSignature
74+
}
75+
let address = AnyAddress(publicKey: publicKey, coin: .ethereum)
76+
return address.description
77+
}
78+
6179
/// Signs an Ethereum transaction using WalletCore's AnySigner pipeline.
6280
public func ethSignTransaction(_ input: EthereumSigningInput, index: UInt32 = 0) throws -> EthereumSigningOutput {
6381
let key = try resolveEthereumKey()
6482
var signingInput = input
6583
signingInput.privateKey = try key.ethPrivateKey(index: index)
6684
defer { signingInput.privateKey = Data() }
67-
return AnySigner.sign(input: signingInput, coin: .ethereum)
85+
var output: EthereumSigningOutput = AnySigner.sign(input: signingInput, coin: .ethereum)
86+
let transactionHash = Hash.keccak256(data: output.encoded)
87+
output.preHash = transactionHash
88+
return output
6889
}
6990

70-
func refreshEOAAddresses() {
91+
public func refreshEOAAddresses() {
7192
guard let key = try? resolveEthereumKey() else {
7293
eoaAddress = nil
7394
return

iOS/FlowWalletKit/Tests/FlowWalletKitTests/EOATests.swift

Lines changed: 81 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -102,53 +102,53 @@ final class EOATests: XCTestCase {
102102
let wallet = Wallet(type: .key(key), networks: [.testnet], cacheStorage: storage)
103103

104104
let typedData = """
105-
{
106-
"types": {
107-
"EIP712Domain": [
108-
{"name": "name", "type": "string"},
109-
{"name": "version", "type": "string"},
110-
{"name": "chainId", "type": "uint256"},
111-
{"name": "verifyingContract", "type": "address"}
112-
],
113-
"Person": [
114-
{"name": "name", "type": "string"},
115-
{"name": "wallets", "type": "address[]"}
116-
],
117-
"Mail": [
118-
{"name": "from", "type": "Person"},
119-
{"name": "to", "type": "Person[]"},
120-
{"name": "contents", "type": "string"}
105+
{
106+
"types": {
107+
"EIP712Domain": [
108+
{"name": "name", "type": "string"},
109+
{"name": "version", "type": "string"},
110+
{"name": "chainId", "type": "uint256"},
111+
{"name": "verifyingContract", "type": "address"}
112+
],
113+
"Person": [
114+
{"name": "name", "type": "string"},
115+
{"name": "wallets", "type": "address[]"}
116+
],
117+
"Mail": [
118+
{"name": "from", "type": "Person"},
119+
{"name": "to", "type": "Person[]"},
120+
{"name": "contents", "type": "string"}
121+
]
122+
},
123+
"primaryType": "Mail",
124+
"domain": {
125+
"name": "Ether Mail",
126+
"version": "1",
127+
"chainId": 1,
128+
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
129+
},
130+
"message": {
131+
"from": {
132+
"name": "Cow",
133+
"wallets": [
134+
"CD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
135+
"DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"
136+
]
137+
},
138+
"to": [
139+
{
140+
"name": "Bob",
141+
"wallets": [
142+
"bBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
143+
"B0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57",
144+
"B0B0b0b0b0b0B000000000000000000000000000"
121145
]
122-
},
123-
"primaryType": "Mail",
124-
"domain": {
125-
"name": "Ether Mail",
126-
"version": "1",
127-
"chainId": 1,
128-
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
129-
},
130-
"message": {
131-
"from": {
132-
"name": "Cow",
133-
"wallets": [
134-
"CD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
135-
"DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"
136-
]
137-
},
138-
"to": [
139-
{
140-
"name": "Bob",
141-
"wallets": [
142-
"bBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB",
143-
"B0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57",
144-
"B0B0b0b0b0b0B000000000000000000000000000"
145-
]
146-
}
147-
],
148-
"contents": "Hello, Bob!"
149146
}
150-
}
151-
"""
147+
],
148+
"contents": "Hello, Bob!"
149+
}
150+
}
151+
"""
152152

153153
let typedDataHash = EthereumAbi.encodeTyped(messageJson: typedData)
154154
XCTAssertEqual(typedDataHash.hexString, "a85c2e2b118698e88db68a8105b794a8cc7cec074e89ef991cb4f5f533819cc2")
@@ -185,6 +185,22 @@ final class EOATests: XCTestCase {
185185

186186
let output = try wallet.ethSignTransaction(input)
187187
XCTAssertEqual(output.encoded.hexString, "f86c098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83")
188+
let expectedHash = Hash.keccak256(data: output.encoded)
189+
XCTAssertEqual(output.preHash.hexString, expectedHash.hexString)
190+
XCTAssertEqual(output.txId().hexString, expectedHash.hexString)
191+
XCTAssertEqual(output.txIdHex(), "0x" + expectedHash.hexString)
192+
}
193+
194+
func testEcRecoverReturnsExpectedAddress() throws {
195+
guard let signature = Data(hexString: "0xa77836f00d36b5cd16c17bb26f23cdc78db7928b8d1d1341bd3f11cc279b60a508b80e01992cb0ad9a6c2212177dd84a43535e3bf29794c1dc13d17a59c2d98c1b") else {
196+
XCTFail("Failed to decode signature")
197+
return
198+
}
199+
let message = Data("Hello, Flow EVM!".utf8)
200+
201+
let recovered = try Wallet.ethRecoverAddress(signature: signature, message: message)
202+
203+
XCTAssertEqual(recovered.lowercased(), "0xe513e4f52f76c9bd3db2474e885b8e7e814ea516")
188204
}
189205

190206
func testInvalidTypedDataThrows() throws {
@@ -241,3 +257,23 @@ private extension Data {
241257
self = result
242258
}
243259
}
260+
261+
private func dataFromHexMinimal(_ hex: String, paddedTo length: Int = 32) -> Data {
262+
var cleaned = hex.lowercased().hasPrefix("0x") ? String(hex.dropFirst(2)) : hex
263+
cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: "0"))
264+
if cleaned.isEmpty {
265+
return Data(count: length)
266+
}
267+
if cleaned.count % 2 != 0 {
268+
cleaned = "0" + cleaned
269+
}
270+
guard let value = Data(hexString: "0x" + cleaned) else {
271+
return Data(count: length)
272+
}
273+
if value.count >= length {
274+
return Data(value.suffix(length))
275+
}
276+
var padded = Data(count: length - value.count)
277+
padded.append(value)
278+
return padded
279+
}

0 commit comments

Comments
 (0)