블록체인 기반의 온/오프라인 귀농교육 애플리케이션
Development Environment
- Xcode 10
- Swift
- iOS 12
- Klaytn Aspen Network
Web3 Pods
Network Pods
UI Pods
기본적으로 MVC (Model-View-Controller) 패턴을 사용합니다.
- View Controller를 재사용 할 수 없는 문제를 XIB를 이용해 CustomView 또는 Cell을 만들어 해결하였습니다.
- 재사용 가능한 모든 function은 Extension을 사용해 쉽게 유지보수가 가능하고, 기능 추가가 쉽도록 만들었습니다.
- web3 인스턴스가 쓰이는 뷰에 재사용 되어서 똑같이 기본 선언을 해주는 것이 바람직하지 않다고 생각했습니다.
- 한번 메모리에 올린 web3 인스턴스나 ABI, contract address, user address 그리고 App Sandbox 내부에 저장된 Keystore 파일들은 APP Life Cycle에서 값을 변경할 일이 없기 때문에 Singleton을 사용하는 것이 적절하다고 판단되었습니다.
- 기본적으로 Swift에서의 Singleton instance는 lazy하기 때문에 인스턴스를 생성하기 전까지 자원을 아낄수 있습니다. (로그인 시 인스턴스 생성)
- Instance 생성전에 CaverSingleton.setUserAddress(userAddress)를 선언함으로써 로그인 한 사용자의 Wallet Address를 Setup 합니다.
- private init()을 사용해 외부에서 값을 변경할 수 없기 때문에 thread-safe한 Singleton임을 보장합니다.
- keystoreMangaerInDevice()를 이용해 App Sandbox내에 생성된 Keystore 폴더를 호출하여 caver 객체에 바인딩 합니다. (Transaction, Sign을 바인딩한 Keystore에서 찾아 로컬에서 수행되기 때문)
- Server에서 ngrox을 사용하여 풀노드를 호스팅 할 예정입니다. (현재는 풀노드의 IPv4 주소 활용)
- Web3.defalut를 initializing 해준뒤 다시 caver 변수에 바인딩 하지 않으면 getBalance를 호출하지 못하는 이슈가 발생했습니다.
- caver라는 변수를 사용하고 싶으면 Web3.default를 한번 거치고 다시 바인딩하여 사용해야 합니다.
- or Web3.default.eth.getBalance()
import web3swift
finalclassCaverSingleton{staticletsharedInstance:CaverSingleton={letinstance=CaverSingleton()iflet fullNodeIP =URL(string:"http://fullNodeIP:port"){Web3.default =Web3(url: fullNodeIP)! // Web3.default는 기본적으로 127.0.0.1:8545를 바라보고 있습니다.
instance.caver =Web3.default
}return instance
}()varcaver:Web3=Web3.default
privateinit(){guardlet setupUserWalletAddress =CaverSingleton.user.walletAddress else{fatalError("Error - you must call setup before accessing CaverSingleton.sharedInstance")}
userAddress = setupUserWalletAddress
}privateclassUser{varwalletAddress:Address?}privatestaticletuser=User()classfunc setUserAddress(_ userAddress:Address){CaverSingleton.user.walletAddress = userAddress
}letcontractAddress=Address("0x96a277b958988d9b4207dda53067fbd787b0e2db")letuserAddress:Addressfunc keystoreMangaerInDevice()->KeystoreManager?{letuserDir=NSSearchPathForDirectoriesInDomains(.documentDirectory,.userDomainMask,true)[0]letpath= userDir+"/keystore"letkeystoreManager=KeystoreManager.managerForPath(path)return keystoreManager
}letcontractABI="SKIP"}EthereumKeystoreV3 aes-128-ctr로 암호화된 Keystore 파일을 iOS FileManager를 사용해 생성하여 App Sandbox내에 Document에 저장, 로컬 Keystore를 만들었습니다.
do{
// Create Keystore File in Local Device
letuserDir=NSSearchPathForDirectoriesInDomains(.documentDirectory,.userDomainMask,true)[0]varkeystore:EthereumKeystoreV3?
keystore =tryEthereumKeystoreV3(password: passwd, aesMode:"aes-128-ctr")letkeydata=tryJSONEncoder().encode(keystore!.keystoreParams)FileManager.default.createFile(atPath: userDir +"/keystore"+"/\(mail).json", contents: keydata, attributes:nil)letwallet=gsno(keystore?.getAddress()?.address)letkeyDataToString=gsno(String(data: keydata, encoding:.utf8))
// Save Keystore Data at the Keychain
ifKeychain.save(key: mail, keystoreData: keydata)==true{
// Request Join to Server
}else{simpleAlert(title:"키체인 오류", msg:"설정(Setting) -> 암호 및 계정 -> iCloud -> 키체인을 켜주세요.")}}catch{print(error.localizedDescription)simpleAlert(title:"회원가입 오류", msg:"개발자에게 문의하세요.")}- iCloud의 Keychain을 활용하여 Server에서 Keystore 파일을 관리하는 것이 아닌, 유저의 iCloud 계정에서 Keystore 파일을 관리합니다.
- 유저의 Device가 변경되더라도, 같은 iCloud 계정이라면 로그인 시도 시, Keystore 파일을 Keychain에서 불러와 다시 로컬 디바이스에 Keystore 파일을 생성합니다.
// If there are no keystore file locally, load keystore data from iCloud Keychain and create keystore file locally
fileprivatefunc loadKeystoreDataFromKeychain(_ mail:String){iflet keystoreData =Keychain.load(key: mail){letuserDir=NSSearchPathForDirectoriesInDomains(.documentDirectory,.userDomainMask,true)[0]FileManager.default.createFile(atPath: userDir +"/keystore"+"/\(mail).json", contents: keystoreData, attributes:nil)successToLogin()}else{simpleAlert(title:"키체인 오류", msg:"가입한 계정과 현재 iCloud 계정이 일치하지 않습니다.")}}테스트용 Contract를 배포하여 Unit Test를 진행하였습니다.
Singleton을 사용했기 때문에 단위 테스트 시 userAddress를 Setup 해주어야 합니다.
overridefunc setUp(){CaverSingleton.setUserAddress(Address("0xf694888fc6eea44f8cd03e9c5f18af8f61bdebe8"))}Smart Contract의 calculateEvaluationAveragePoint를 호출하는 test case 입니다.
- iOS GCD 클래스의 Global Queue를 이용하여 UI를 처리하는 Main Thread에서 호출되지 않도록 했습니다.
- 단일 강의 평가점수 호출은 빠르나 실제 강의 목록에서 여러 강의의 점수를 불러오는 경우 UI Delay되는 현상이 발생하여 최초 로딩 시 한번에 호출하였습니다.
- KlaytnExtenion.swift를 만들어 UIView에 extension 시켜 function을 활용하였습니다.
func testCalculateEvaluationAveragePoint(){varvalue:Int?letlectureNumber:Int=0DispatchQueue.global(qos:.utility).async{do{letcontractAddress=CaverSingleton.sharedInstance.contractAddress
value =try contractAddress.call("calculateEvaluationAveragePoint(uint256)", lectureNumber).wait().intCount()print(value!)}catch{print("Get Function Result Fail!")print(error.localizedDescription)}}}보유 KLAY와 private key를 불러오는 test case 입니다.
func testGetKlayBalances(){letcaver=CaverSingleton.sharedInstance.caver
letuserAddress=CaverSingleton.sharedInstance.userAddress
letkeystore=CaverSingleton.sharedInstance.keystoreMangaerInDevice()varuserKlay:BigUInt=0varprivateKey:Data?DispatchQueue.global(qos:.utility).async{do{
userKlay =try caver.eth.getBalance(address: userAddress)
privateKey =try keystore?.UNSAFE_getPrivateKeyData(password:"비밀번호", account: userAddress)print(userKlay)print(privateKey?.toHexString())}catch{print("Get Klay Balance Fail")}}}
// Test Case '-[ChungChul_iOSTests.ChungChul_iOSTests testGetKlayBalances]' passed (0.224 seconds).강의를 신청(구매)하는 Transaction test case 입니다.
func testPurchaseLecture(){letinstance=CaverSingleton.sharedInstance
letcaver= instance.caver
letABI= instance.contractABI
letcontractAddress= instance.contractAddress
letpasswd="비밀번호"letlecturePrice=10letlectureNumber=BigUInt(0)DispatchQueue.global(qos:.utility).async{do{
// Option Setting
varoptions=Web3Options.default
options.value =Web3.Utils.parseToBigUInt("\(lecturePrice)", units:.eth)
options.gasLimit =BigUInt(701431)
options.from =CaverSingleton.sharedInstance.userAddress
// Parameter Setting
letlectureNumberParameter=[lectureNumber]as[AnyObject]
// Estimated Gas
letestimatedGas=try caver.contract(ABI, at: contractAddress).method("purchaseLecture", parameters: lectureNumberParameter, options: options).estimateGas(options:nil)
options.gasLimit = estimatedGas
// Transaction Setting
lettransactionResult=try caver.contract(ABI, at: contractAddress).method("purchaseLecture", parameters: lectureNumberParameter, options: options)
// Transaction Send
letsendingResult=try transactionResult.send(password: passwd)print(sendingResult.transaction)}catch{print("You don't have enough KLAY!")print(error.localizedDescription)}}}강의를 평가하는 Transaction test case 입니다.
- 블록에 기록된것이 보장된 후 Server와 통신하기 위해 global queue 안에서 결과를 받고 Main Thread에서 통신하였습니다.
func testEvaluateLecture(){letinstance=CaverSingleton.sharedInstance
letcaver= instance.caver
letABI= instance.contractABI
letcontractAddress= instance.contractAddress
letpasswd="비밀번호"
// Option Setting
varoptions=Web3Options.default
options.gasLimit =BigUInt(701431)
options.from = instance.userAddress
letlectureNumber=BigUInt(0)letpreparationPoint=BigUInt(100)letcontentPoint=BigUInt(80)letproceedPoint=BigUInt(34)letcommunicationPoint=BigUInt(77)letsatisfactionPoint=BigUInt(99)
// Parameter Setting
letevaluateParameters=[lectureNumber, preparationPoint, contentPoint, proceedPoint, communicationPoint, satisfactionPoint]as[AnyObject]DispatchQueue.global(qos:.utility).async{do{
// Estimated Gas
letestimatedGas=try caver.contract(ABI, at: contractAddress).method("evaluateLecture", parameters: evaluateParameters, options: options).estimateGas(options:nil)
options.gasLimit = estimatedGas
// Transaction Setting
lettransactionResult=try caver.contract(ABI, at: contractAddress).method("evaluateLecture", parameters: evaluateParameters, options: options)
// Transaction Send
letsendingResult=try transactionResult.send(password: passwd)print(sendingResult.transaction)DispatchQueue.main.async{
// Request Network Logic to Server
}}catch{print("You don't have enough KLAY!")print(error.localizedDescription)}}}