Skip to content
Draft
2 changes: 1 addition & 1 deletion Shared (App)/Models/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by Stefano on 05.12.21.
//

struct Contract {
struct Contract: Hashable {
let address: String
let name: String
let abi: String
Expand Down
36 changes: 36 additions & 0 deletions Shared (App)/Models/Method.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// Method.swift
// Wallet
//
// Created by Stefano on 21.12.21.
//

import Foundation

struct MethodInfo {
let contractAddress: String
let methodId: String
let description: MethodDescription
}

struct MethodDescription {
let stringFormat: String
let arguments: [String]
}

extension MethodInfo: Decodable {

enum CodingKeys : String, CodingKey {
case contractAddress = "Address"
case methodId = "MethodId"
case description = "Description"
}
}

extension MethodDescription: Decodable {

enum CodingKeys : String, CodingKey {
case stringFormat = "StringFormat"
case arguments = "Arguments"
}
}
21 changes: 21 additions & 0 deletions Shared (App)/Models/MethodInfo.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>Description</key>
<dict>
<key>StringFormat</key>
<string>Registered %@</string>
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you guys think of this approach? Maintaining a list of string formats per contract/methodId so we can display the tx input in a descriptive way, instead of dumping the method name and its arguments? But it's something we'd have to maintain over time...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maintaining a list is a good idea. While it involves more upfront work, and the ongoing maintenance cost, it's the best approach we have so far to provide really clear, user-friendly descriptions of transactions.

One question: why a plist? Would it not be better to put this in Swift directly, perhaps a map from address+method to a callback? This way, we can not only return text, but also other pieces of UI (logos, links, perhaps even in-wallet actions?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to our discussions, this approach is still a bit limited as it assumes that the parameter value’s string representation can be readily displayed. However, in some cases we will need to do some further formatting, e.g. when the input parameter is a timestamp, we would like to format it in a readable way. One way we could handle this is to add an additional type argument which tells the client how to format the value. wdyt?

Copy link
Copy Markdown
Contributor

@DimitarNestorov DimitarNestorov Dec 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, I agree. What about using String(format:_:), or another string formatting API, or a library? https://riptutorial.com/swift/example/6342/formatting-strings

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right! So in the current PR String(format:arguments:) is used (see TransactionInputParser). So we should be able to handle different format specifiers. But if we wanted to do date formatting for example then string formats wouldn't be sufficient anymore.

There are also a couple of other things to consider:

  • The format specifier used in the string format has to match the type of the decoded input param.
  • The list of arguments provided in the plist/json to be in the right order. Also each argument in the plist/json needs to match the param name of the decoded input.

It's easy for errors to creep in here. When the list contains errors in the string format or the supplied argument names then it will be difficult to catch. We will need to think about how we can add further validation perhaps, and/or how to properly test updates in the string format list.

<key>Arguments</key>
<array>
<string>name</string>
</array>
</dict>
<key>Address</key>
<string>0x283af0b28c62c092c9727f1ee09c02ca627eb7f5</string>
<key>MethodId</key>
<string>f7a16963</string>
</dict>
</array>
</plist>
8 changes: 8 additions & 0 deletions Shared (App)/Models/TransactionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ struct TransactionGroup: Comparable, Identifiable {

var contractName: String?

var inputData: String?

var methodName: String?

var input: [String: String]?

var inputDescription: String?

static func < (lhs: TransactionGroup, rhs: TransactionGroup) -> Bool {
guard let lhsBlock = lhs.transactions.first?.block.intValue,
let rhsBlock = rhs.transactions.first?.block.intValue else {
Expand Down
32 changes: 32 additions & 0 deletions Shared (App)/Models/TransactionInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// TransactionInput.swift
// Wallet
//
// Created by Stefano on 17.12.21.
//

import MEWwalletKit

typealias InputName = String

struct TransactionInput {
let method: Method
let inputData: [InputName: InputData]
}

struct Method {
let name: String
let signature: String
let hash: String
let inputs: [InputParameter]
}

struct InputParameter {
let name: InputName
let type: ABI.Element.ParameterType
}

struct InputData {
let parameter: InputParameter
let data: Any
}
17 changes: 8 additions & 9 deletions Shared (App)/Services/ContractService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SafariWalletCore

protocol ContractFetchable {
func name(forAddress address: RawAddress) -> ContractInfo?
func fetchContractDetails(forAddress: Address) async throws -> Contract?
func fetchContractDetails(forAddress: RawAddress) async throws -> Contract?
}

typealias RawAddress = String
Expand All @@ -33,15 +33,14 @@ final class ContractService: ContractFetchable {
return contractNameLookup[address]
}

func fetchContractDetails(forAddress address: Address) async throws -> Contract? {
// TODO: fetch contract details (ABI & Contract name)
// let response = try await client.getContractDetails(forAddress: address)
// guard let contractDetails = response.result.first else { return nil }
let contractInfo = name(forAddress: address.address)
func fetchContractDetails(forAddress address: RawAddress) async throws -> Contract? {
let response = try await client.getContractDetails(forAddress: address)
guard let contractDetails = response.result.first else { return nil }
let contractInfo = name(forAddress: address)
let contract = Contract(
address: address.address,
name: "", //contractDetails.contractName,
abi: "", //contractDetails.abi,
address: address,
name: contractDetails.contractName,
abi: contractDetails.abi,
nameTag: contractInfo?.nameTag
)
return contract
Expand Down
63 changes: 63 additions & 0 deletions Shared (App)/Services/TransactionDecoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// TransactionDecoder.swift
// Wallet
//
// Created by Stefano on 21.12.21.
//

import Foundation
import MEWwalletKit
import UIKit

protocol TransactionDecodable {
func decode(input: String, with contract: Contract) -> TransactionInput?
}

struct TransactionDecoder: TransactionDecodable {

func decode(input: String, with contract: Contract) -> TransactionInput? {
guard let data = contract.abi.data(using: .utf8),
let abiRecords = try? JSONDecoder().decode([ABI.Record].self, from: data) else {
return nil
}
for record in abiRecords {
let element = try? record.parse()
if case .function(let function) = element {
let signature = function.signature
let decodedMethodHash = function.methodString
let data = Data(hex: input)
guard !data.isEmpty, data.count > 4 else { return nil }
let methodHash = data[0 ..< 4].dataToHexString()
if methodHash == decodedMethodHash, let decodedInput = element?.decodeInputData(data) {
Comment thread
DimitarNestorov marked this conversation as resolved.
let parameters = getInputParameters(of: function)
let inputData = mapToInputDataFrom(parameters: parameters, input: decodedInput)
return TransactionInput(
method: Method(
name: function.name ?? "",
signature: signature,
hash: methodHash,
inputs: parameters
),
inputData: inputData
)
}
}
}
return nil
}

private func getInputParameters(of function: ABI.Element.Function) -> [InputParameter] {
function.inputs.map { InputParameter(name: $0.name, type: $0.type) }
}

private func mapToInputDataFrom(parameters: [InputParameter], input: [String: Any]) -> [InputName: InputData] {
let inputData = parameters.compactMap { param -> (InputName, InputData)? in
guard let data = input[param.name] else { return nil }
return (param.name, InputData(parameter: param, data: data))
}
return Dictionary(
inputData,
uniquingKeysWith: { (first, _) in first }
)
}
}
75 changes: 75 additions & 0 deletions Shared (App)/Services/TransactionInputParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// TransactionInputParser.swift
// Wallet
//
// Created by Stefano on 21.12.21.
//

import BigInt
import Foundation
import MEWwalletKit

protocol TransactionInputParseable {
func parse(input: [String: String], methodHash: String, contractAddress: RawAddress) -> String?
func parseToStringValues(input: TransactionInput) -> [InputName: String]
}

typealias MethodID = String

final class TransactionInputParser: TransactionInputParseable {

private lazy var methodInfoLookup: [RawAddress: [MethodID: MethodInfo]] = {
let decoder = PropertyListDecoder()

guard let url = Bundle.main.url(forResource: "MethodInfo", withExtension: "plist"),
let data = try? Data(contentsOf: url),
let methodInfo = try? decoder.decode([MethodInfo].self, from: data) else { return [:] }

return methodInfo.reduce([RawAddress: [MethodID: MethodInfo]]()) { partialResult, info in
var methodInfo = partialResult
if methodInfo[info.contractAddress] != nil {
methodInfo[info.contractAddress]?[info.methodId] = info
} else {
methodInfo[info.contractAddress] = [info.methodId: info]
}
return methodInfo
}
}()

func parse(input: [String: String], methodHash: String, contractAddress: RawAddress) -> String? {
guard let methodInfo = methodInfoLookup[contractAddress]?[methodHash] else { return nil }
let arguments = methodInfo.description.arguments.compactMap { input[$0] }
return String(format: methodInfo.description.stringFormat, arguments: arguments)
}

func parseToStringValues(input: TransactionInput) -> [InputName: String] {
input.inputData.reduce([InputName: String]()) { partialResult, input in
var stringValues = partialResult
stringValues[input.key] = convertRawValueToString(inputData: input.value)
return stringValues
}
}

private func convertRawValueToString(inputData: InputData) -> String? {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just simple conversion to string values right now so we can display it on the tx history detail page for demo purposes.

switch inputData.parameter.type {
case .uint:
return (inputData.data as? BigUInt).flatMap(String.init)
case .int:
return (inputData.data as? BigInt).flatMap(String.init)
case .address:
return (inputData.data as? Address).flatMap { $0.address }
case .function:
return "n/a"
case .bool:
return (inputData.data as? Bool).flatMap(String.init)
case .bytes, .dynamicBytes:
return ABIEncoder.convertToData(inputData.data as AnyObject)?.toHexString()
case .string:
return inputData.data as? String
case .array:
return "n/a"
case .tuple:
return "n/a"
}
}
}
22 changes: 14 additions & 8 deletions Shared (App)/Services/TransactionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,34 @@ protocol TransactionFetchable {
final class TransactionService: TransactionFetchable {

private var currentPage = 1
private let unmarshalClient: UnmarshalClient? = UnmarshalClient(apiKey: ApiKeys.unmarshal)
private let offset = 20
private let unmarshalClient: UnmarshalClient = UnmarshalClient(apiKey: ApiKeys.unmarshal)
private let etherscanClient: EtherscanClient = EtherscanClient(apiKey: ApiKeys.etherscan)
var etherscanTransactions: [String : Etherscan.Transaction] = [:]

@MainActor
func fetchTransactions(network: Network, address: Address) async throws -> [TransactionGroup] {

guard let client = unmarshalClient else { throw TransactionError.networkConnectionFailed }

let unmarshalResponse = try await client.getTransactions(address: address, page: currentPage)
let unmarshalResponse = try await unmarshalClient.getTransactions(address: address, page: currentPage, pageSize: offset)
let etherscanResponse = try await etherscanClient.getTransactions(address: address.address, page: currentPage, offset: offset + 20)
let etherscanTransactionsDict = Dictionary(
uniqueKeysWithValues: etherscanResponse.result.map { ($0.hash, $0) }
)
etherscanTransactions = etherscanTransactions.merging(etherscanTransactionsDict) { (_, new) in new }

guard currentPage < unmarshalResponse.total_pages else { return [] }

var walletTransactions = [WalletTransaction]()
// TODO: Replace TransactionGroup
var walletTransactions = [WalletTransaction]()
walletTransactions.append(contentsOf: unmarshalResponse.transactions)

var hashGroup = [String: TransactionGroup]()
for transaction in walletTransactions {
if var group = hashGroup[transaction.hash] {

group.transactions.append(transaction)
hashGroup[transaction.hash] = group
} else {
let newGroup = TransactionGroup(transactionHash: transaction.hash, transactions: [transaction])
var newGroup = TransactionGroup(transactionHash: transaction.hash, transactions: [transaction])
newGroup.inputData = etherscanTransactions[newGroup.transactionHash]?.input
hashGroup[transaction.hash] = newGroup
}
}
Expand Down
26 changes: 22 additions & 4 deletions Shared (App)/TransactionsTab/TransactionDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,36 @@ struct TransactionDetailsView: View {
ForEach(group.transactions, id: \.source) { transaction in
if let alchemyTransfer = transaction as? AlchemyAssetTransfer {
VStack {
Text(alchemyTransfer.source).font(.title)
Text(alchemyTransfer.source)
.font(.title)
.bold()
Text(alchemyTransfer.description)
}
} else if let covalentTransaction = transaction as? Covalent.Transaction {
VStack {
Text(covalentTransaction.source).font(.title)
Text(covalentTransaction.source)
.font(.title)
.bold()
Text(covalentTransaction.description)
}
} else if let unmarshalTransaction = transaction as? Unmarshal.TokenTransaction {
VStack {
Text(unmarshalTransaction.source).font(.title)
VStack(alignment: .leading, spacing: 10) {
Text(unmarshalTransaction.source)
.font(.title)
.bold()
Text(unmarshalTransaction.description)
if let input = group.input {
Text("Transaction input")
.font(.title2)
.bold()
ForEach(input.sorted(by: >), id: \.key) { key, value in
VStack(alignment: .leading, spacing: 10) {
Text(key)
.bold()
Text(value)
}
}
}
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions Shared (App)/TransactionsTab/TransactionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,17 @@ struct TransactionsView: View {
List {
switch viewModel.state {
case .loading:
ForEach(1..<6) { transactionGroup in
// TransactionRow(tx: .placeholder)
// .redacted(reason: .placeholder)
HStack {
Spacer()
ProgressView()
Spacer()
}
case .fetched(txs: let txs):
ForEach(txs) { transactionGroup in
NavigationLink(destination: TransactionDetailsView(group: transactionGroup)) {
TransactionRowView(
txType: TransactionType(transactionGroup.type),
description: transactionGroup.description,
description: transactionGroup.inputDescription ?? transactionGroup.methodName ?? transactionGroup.description,
// toAddress: transactionGroup.toAddress,
toAddress: transactionGroup.contractName ?? "",
amount: transactionGroup.value
Expand Down
Loading