Ouryn
은행 앱처럼 쉽게 시작하는 멀티체인 자기수탁 지갑.
체인에서 생기는 위험을 코인 용어 대신 화면 흐름으로 막아 주는 한국어 iOS 자기수탁 지갑입니다. 7개 네트워크를 지원하고, 송금은 기기 안에서 서명합니다.
- 기간
- 2026.09.18 – 진행 중
- 출처
- 개인 프로젝트
- 플랫폼
- iOS · SwiftUI · Trust Wallet Core
- 범위
- 7개 네트워크 · 16개 자산
이 페이지의 목차
문제
코인을 거래소 밖으로 처음 꺼내려는 사람은 첫 단계부터 막힙니다. 지갑을 만들 때 받는 12단어가 무엇인지, 지갑 주소가 무엇인지 모르는 상태에서, 거래소에서 내 지갑으로 출금하는 방법과 첫 송금을 혼자 해내야 합니다. 첫 송금은 실수하면 돈을 되찾기 어렵기 때문에, 이 과정이 입문의 가장 큰 장벽이 됩니다.
여기에 체인 자체의 위험이 겹칩니다. 똑같은 USDT라도 트론·솔라나·이더리움에 따로 있고, 0x 주소는 이더리움·맨틀·Avalanche에서 모양이 같아서 주소만으로는 어느 네트워크인지 알 수 없습니다. 네트워크를 잘못 고르면 돈을 돌려받기 어렵고, 보낸 뒤 응답이 끊기면 다시 보내다가 두 번 나갈 수도 있습니다.
기존 지갑은 체인 용어와 주소 문자열을 그대로 보여 주고 판단을 사용자에게 맡깁니다. 은행·간편송금 앱에만 익숙한 사람에게는 이 과정을 넘기 어렵습니다. 그래서 지갑 만들기부터 첫 출금, 첫 송금까지를 사용자의 지식이 아니라 UX로 풀어야 할 문제로 봤습니다.
해결 개요
사용자는 은행 앱에서 쓰던 말("내 돈 · 보내기 · 받기", "통장", "내역")로 앱을 쓰고, 체인별 차이는 앱이 안에서 처리합니다. 네트워크를 추측할 수 없는 순간(0x 주소, 테스트넷 주소)에만 사용자에게 고르게 하고, 서명 전에는 받는 주소·수량·수수료 상한을 한 화면에 보여 줍니다. 전파한 거래는 해시로 끝까지 추적하고, 응답이 불확실해도 새 거래를 만들지 않습니다.
- 01주소 입력붙여넣기, 카메라 QR, 앨범 QR이 모두 같은 파서로 들어간다.
- 02네트워크 확정주소 모양과 체인 번호로 알아보고, 알 수 없는 0x 주소만 사용자가 고른다.
- 03견적 · 검증잔액, 수수료 상한, chain ID를 확인하고 확정 안 된 송금이 있으면 막는다.
- 04본인 확인 후 기기 내 서명Face ID를 통과해야 키를 꺼내고, 서명 직전에 견적과 주소를 다시 확인한다.
- 05전파 · 해시 추적해시를 먼저 저장하고 전파한 뒤, 네트워크가 확정할 때까지 같은 거래만 추적한다.
구조
화면은 체인을 모르고 공통 견적(TransferQuote)만 다룹니다. 체인별 차이는 어댑터(견적·서명)와 레지스트리(chain ID·계약·RPC)에만 있고, 전파한 거래는 해시를 먼저 저장한 뒤 상태를 추적합니다.
| 구성 요소 | 하는 일 | 위치 |
|---|---|---|
| 화면 | 홈·받기·보내기·내역·주소록·가져오기·잠금 | Screens/ |
| 입력 파서 | 주소·QR 문자열을 주소·네트워크·자산·금액 요청으로 해석 | Core/Pay.swift |
| 네트워크 레지스트리 | 체인별 chain ID, 수수료 자산, 토큰 계약, 자릿수, RPC, 탐색기 주소 | Core/Networks.swift |
| 체인 어댑터 | 견적(잔액·수수료 상한)과 서명 | Core/*Transfer.swift |
| 전파·추적 | 전파, 체인별 만료 판정 | TransferDispatcher |
| 키 보관 | 12단어 저장·꺼내기, 주소 파생, 자체 검사 | Core/Keys.swift, PinVault.swift |
| 거래 기록·상태 | 통장·잔액·내역, 미확정 거래, 주소록 | Core/Store.swift |
| 잔액·시세 조회 | 공개 API 호출, chain ID 확인 | Core/MultiChainAPI.swift |
| 위젯 확장 | 결제 모드(Live Activity), 제어 센터 "Ouryn 결제" | OurynWidgets/ |
내가 맡은 부분
가장 큰 작업은 체인의 위험을 화면 결정으로 바꾼 것입니다. 아래 첫 번째 항목의 결정은 대부분 "보기 좋게"가 아니라 "돈을 잃지 않게"를 위한 것이고, 나머지는 그 결정을 받쳐 주는 구조·흐름·보안·검증입니다.
금융 앱 사용자를 위한 입문 UX
은행·간편송금 앱에 익숙한 사람이 코인 지식 없이도 지갑에 입문할 수 있게, 금융 앱에서 이미 아는 개념(통장, 내역, 받기·보내기, 주소록)으로 화면과 말을 짰습니다. 앱 문구는 영어 없이 한글 해요체로 씁니다. 로고(열린 O + 워드마크 + "Your Money. Yours.")를 직접 만들었고, 앱 전체는 차콜 배경에 로고의 메탈 느낌만 살렸습니다.
체인에서 돈을 잃는 경로 하나마다 화면 결정 하나로 막았습니다.
| 결정 | 고른 것 | 버린 선택지 | 이유 |
|---|---|---|---|
| 계정 구조의 말 | 지갑 = 12단어 하나(은행), 통장 = 같은 12단어의 n번째 계정(계좌) | MetaMask식 "Account 1, 2" | 통장을 늘려도 새로 적을 게 없고, 은행·계좌 비유라 설명이 쉽다 |
| 연습용 환경 | "테스트 통장": 그 통장에 있을 때만 앱 전체가 테스트넷 | 설정의 네트워크 토글 | 토글은 켜 둔 걸 잊기 쉽다. 홈 상단에 "테스트 통장 · 실제 가치 없음"이 항상 보인다 |
| 테스트넷↔메인넷 주소 | 통장과 안 맞으면 보내기를 막고 전환 버튼 하나만 | 경고 후 진행 허용 | 앱이 알 수 있는 것은 사용자에게 판단을 넘기지 않는다 |
| 0x 주소 | 네트워크를 추측하지 않고 사용자가 고르게 함 | 이더리움 기본 선택 | 기본값이 있으면 확인 없이 넘어간다 |
| 주소 표시 | 앞뒤 6자를 다른 색으로 강조, 확인 화면에는 주소 전체 | "0x12…ab"처럼 줄임 | 사람은 주소를 앞뒤만 보고 비교한다 |
| 내 주소 | 내 다른 통장과 주고받은 거래는 통장 이름으로 표시 | 막기 | 내 통장끼리 옮기는 건 정상 행동이다 |
| 코인 구매 | "거래소에서 가져오기" 안내, 15초마다 실제 잔액 증가 확인 | 앱 내 구매 | 사용자는 이미 국내 거래소에서 코인을 산다. 지갑까지 데려오는 흐름이 먼저다 |
| 홈 화면 | 가격 그래프는 코인 상세에서 펼침 | 홈에 시세 차트 | 금액 변동은 알리되, 거래소처럼 보이면 안 된다 |
| 하단 탭 | 홈 · 내역 · 가운데 O 결제 버튼 · 주소록 · 전체 | 카드 탭 유지 | 출시 전 기능이 탭 하나를 차지하면 안 된다. 로고가 곧 결제 버튼 |
| 로고 노출 | "결제 모드": 켜면 최대 8시간 다이나믹 아일랜드에 로고와 QR 버튼 | 로고를 항상 띄우기 | Live Activity는 시작·끝이 있는 작업에만 쓰라는 HIG 기준 |
| QR 스캔 | 로고의 열린 O 링이 돌다가 인식하면 QR 위치로 붙으며 닫힘 | 네모 모서리 + 레이저 선 | 브랜드를 매일 쓰는 동작에 넣음. 타이밍표까지 시안으로 직접 정함 |
| 원형 QR | 원을 점으로 채운 전용 코드, 속은 표준 QR(오류 복원 Q) | 장식 눈 4개 | 장식 눈을 넣으면 방향 인식이 헷갈려 인식률이 크게 떨어졌다 |
동작 줄이기가 켜져 있으면 회전·빛·번쩍임·흔들림을 끕니다. 주요 버튼은 44pt 이상, 보조 글씨 대비는 4.5:1 이상으로 맞추고, "수수료 무료" 같은 확정 혜택처럼 보이는 문구는 지웠습니다.
시뮬레이터에서 새 지갑 만들기부터 끝까지 따라가며 18개 항목의 수정 목록을 만들었습니다(2026.09.25). 돈·열쇠와 직결된 항목을 먼저, 흐름이 막히는 항목을 다음에 반영했습니다.
첫 실행에서 20초 넘게 검은 화면이 떴습니다. 원인은 재현되지 않아 확정하지 못했고, 로고 런치 화면을 넣고 Keychain 읽기·주소 자체 검사를 백그라운드로 옮겨 첫 화면을 바로 그리게 했습니다.
원형 QR 금액 요청은 2026.09.23 실기기 두 대로 요청 → 스캔 → 자동 입력까지 확인했습니다. check_scan.py가 인식 → 결과, 중복 프레임 무시, 동작 줄이기를 검사합니다.
멀티체인 구조: 체인별 차이를 감싸는 방법
12단어 하나에서 7개 네트워크 주소를 만들고, 16개 자산 항목의 잔액·받기·보내기를 하나의 흐름에 올렸습니다. 지원 네트워크와 자산 범위는 제가 정했습니다.
| 네트워크 | 자산 | 송금에서 다른 점 |
|---|---|---|
| Tron | USDT(TRC-20), TRX | 계정 활성화 필요. Energy·Bandwidth 기반 수수료, fee_limit 필수 |
| Solana | USDC, USDT, SOL | 최신 blockhash가 만료됨. 서명 후 사전 검사 |
| Ethereum | USDC, USDT, ETH | chain ID, nonce. ERC-20은 컨트랙트 호출 |
| Mantle | MNT, USDT0 | EVM 엔진 공유, chain ID 5000 |
| Avalanche C-Chain | USDC, USDT, AVAX | EVM 엔진 공유, chain ID 43114 |
| Bitcoin | BTC | UTXO 선택, 거스름돈 |
| Cardano | ADA, NIGHT | UTXO, 토큰에 최소 ADA를 함께 보냄, CBOR |
세 안을 놓고 세 번째를 골랐습니다.
- 기존 문자열 맵에 체인만 추가: 구현은 짧지만 USDT 잔액·수수료·입금 추적이 체인 사이에서 섞인다.
- 외부 지갑에 서명 위임: 송금 구현은 줄지만 Ouryn 자체 지갑 경험이 아니다.
- 네트워크·자산 식별자 + 체인별 어댑터 (채택): 키는 기기 안에 두고, 체인마다 다른 부분만 어댑터가 맡는다.
그 위에 네 가지 규칙을 두었습니다.
- 잔액의 기본 단위는 (network, asset, account)입니다. 홈에는 USDT 전체를 합산해 보여 주지만, 보낼 수 있는 금액은 고른 네트워크의 잔액뿐입니다.
- 맨틀 USDT0는 발행사 문서로 계약을 확인해 USDT와 다른 자산으로 표시합니다. 계약을 확인하지 못한 토큰은 넣지 않았습니다.
- 금액은 정수 문자열(최소 단위)로 다루고 계산에 Double을 쓰지 않습니다.
- EVM 세 네트워크는 주소가 같아도 잔액·송금 상태는 따로입니다. RPC에 붙을 때마다
eth_chainId를 등록된 값과 비교합니다.
네트워크 레지스트리. 체인별 차이(chain ID, 수수료 자산, 토큰 계약, RPC)를 한 enum에 모았고, 테스트 통장이면 같은 자리에서 테스트넷 값이 나옵니다.
enum WalletNetwork: String, Codable, CaseIterable, Identifiable {
case tron = "트론", solana = "솔라나", ethereum = "이더리움", mantle = "맨틀",
avalanche = "Avalanche C-Chain", bitcoin = "비트코인", cardano = "카르다노"
var chainID: Int? {
if Testnet.on { switch self { case .ethereum: return 11155111; case .mantle: return 5003; case .avalanche: return 43113; default: return nil } }
switch self { case .ethereum: return 1; case .mantle: return 5000; case .avalanche: return 43114; default: return nil }
}
func contract(_ asset: String) -> String? {
if Testnet.on { switch (self,asset) {
case (.ethereum,"usdc"): return "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" // Sepolia (Circle)
/* … */ default: return nil } }
switch (self,asset) {
case (.mantle,"usdt0"): return "0x779Ded0c9e1022225f8E0630b35a9b54bE713736" // usdt0.to/ecosystem/mantle
case (.ethereum,"usdt"): return "0xdAC17F958D2ee523a2206206994597C13D831ec7"
/* … */ default: return nil }
}
}공통 견적 형태와 디스패처. 체인마다 견적을 만드는 방법은 다르지만 결과는 같은 TransferQuote입니다. 그래서 확인 화면·본인 확인·기록·추적은 체인을 몰라도 됩니다.
struct TransferQuote {
let network: WalletNetwork
let asset: String
let owner: String
let recipient: String
let raw: String
let fee: Decimal
let expires: Date
let draft: [String:Any]
let maxFee: Int64
}
enum TransferDispatcher {
static func broadcast(_ signed:SignedTransfer,network:WalletNetwork) async throws {
switch network {
case .tron: try await TronTransfer.broadcast(signed)
case .solana: try await SolanaTransferEngine.broadcast(signed)
case .bitcoin: try await BitcoinTransfer.broadcast(signed)
case .cardano: try await CardanoTransfer.broadcast(signed)
default: try await EVMTransfer.broadcast(signed,network:network)
}
}
}정밀한 금액. 사용자가 입력한 "12.5"를 자산 자릿수에 맞는 정수 문자열로 바꿉니다. 자릿수보다 긴 소수는 버리지 않고 거부합니다.
static func parse(_ text: String, decimals: Int) throws -> String {
guard (0...18).contains(decimals), !text.isEmpty else { throw AmountError.invalid }
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count <= 2, !parts[0].isEmpty,
parts.allSatisfy({ $0.allSatisfy { $0 >= "0" && $0 <= "9" } }) else { throw AmountError.invalid }
let fraction = parts.count == 2 ? String(parts[1]) : ""
guard fraction.count <= decimals else { throw AmountError.invalid }
let raw = String(parts[0]) + fraction + String(repeating: "0", count: decimals - fraction.count)
let stripped = String(raw.drop(while: { $0 == "0" }))
guard stripped.count <= 38 else { throw AmountError.invalid }
return stripped.isEmpty ? "0" : stripped
}- 실제 메인넷 API로 확인해 보니 솔라나 네트워크 식별자에 잘린 genesis 해시를 쓰고 있었습니다. RPC의 전체 genesis 해시와 비교하도록 고쳤습니다.
- 비트코인 기본 서버(mempool.space)가 시간 초과되어, 잔액·UTXO·수수료·전파·상태 조회에 Blockstream Esplora 대체 경로를 넣었습니다.
- 설치된 Wallet Core 4.8.3에는 트론 rawJson 서명이 없었습니다. SDK를 올리는 대신, 명시한 송금 의도와 블록 헤더로 거래를 직접 구성했습니다.
- 같은 12단어라도 앱마다 주소 규칙이 달라, 가져온 지갑의 잔액이 0으로 보일 수 있었습니다. 가져올 때 두 가지 규칙(Phantom·MetaMask 방식, Trust Wallet·Ledger 방식)으로 0~4번 계정을 조회해 기록이 있는 번호까지 가져옵니다.
송금 흐름: 주소 입력부터 확정까지
보내기 화면 하나가 홈 보내기, 코인 상세, 주소록, "다시 보내기"의 공통 경로입니다. 모든 체인을 한꺼번에 열지 않고, 체인별 구현과 검증이 끝난 것만 열기로 했습니다.
- 주소 입력. 붙여넣기·카메라 QR·앨범 QR이 모두 같은 파서
parsePay로 들어갑니다. 지원하지 않는 형식은 "지원하는 지갑 주소 QR이 아니에요"로 멈춥니다. - 네트워크 확정. 비트코인·카르다노는 주소 모양으로, EVM은 EIP-681의 체인 번호로 알아봅니다. 체인 번호 없는 0x 주소는 사용자가 고릅니다.
- 견적·검증. 같은 네트워크에 확정 안 된 송금이 있으면 막습니다. EVM은
eth_estimateGas의 1.2배, 트론 USDT는 예상 Energy의 1.3배를 수수료 상한으로 잡습니다. - 확인 화면 "이렇게 보낼게요". 코인과 네트워크, 받는 주소 전체, 수량, 수수료 상한을 보여 줍니다. 견적은 45초 뒤 만료됩니다.
- 본인 확인과 서명. Face ID를 통과해야 Keychain에서 12단어가 나오고, 서명하는 동안만 메모리에 둡니다. 서명 직전에 견적 만료, 서명 키의 주소, chain ID를 다시 확인합니다.
- 기록 후 전파. 서명된 거래의 해시를 먼저 저장하고 전파합니다. 응답이 불확실하면 "거래 해시로 확인 중이며 다시 보내지 마세요"를 보여 줍니다.
- 확정 추적. 확정 전에는 같은 서명 거래만 다시 전파하고, 새 거래를 자동으로 만들지 않습니다.
서버 장애나 응답 유실은 실패로 보지 않습니다. 네트워크가 그 거래를 더는 받을 수 없다는 것이 확인될 때만 실패로 확정합니다. 사용자가 "안 간 줄 알고" 다시 보내 두 번 나가는 일을 막기 위해서이고, 그 기준은 체인마다 다릅니다.
| 네트워크 | 실패로 확정하는 조건 |
|---|---|
| Solana | 거래가 없고, finalized 블록 높이가 거래의 유효 높이를 넘음 |
| Cardano | 확인 수가 없고, 현재 슬롯이 유효 슬롯 + 300을 넘음 |
| EVM | 확정된 nonce가 이 거래의 nonce를 넘었는데 이 거래는 영수증도 거래 자체도 없음 |
| Bitcoin | 쓰려던 입력(txid:vout)을 다른 거래가 먼저 씀 |
| Tron | 거래가 없고, 확정 블록 시각이 만료 + 60초를 넘음 |
72시간 넘게 확정되지 않으면 "지연"으로 표시하고, 다시 전파하는 간격을 30초에서 10분으로 늘립니다.
입력 파서. 0x 주소에 network: nil을 돌려주는 줄이 "추측하지 않기" 결정입니다.
func parsePay(_ raw: String) -> PayRequest? {
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// 주소 모양으로 네트워크가 갈리는 것: 비트코인 tb1/bc1 · 카르다노 addr_test1/addr1
if Testnet.looksTest(s) || Testnet.looksMain(s) {
let test = Testnet.looksTest(s)
guard let n = WalletNetwork.allCases.first(where: { WalletKeys.valid(s, network: $0, test: test) }) else { return nil }
return PayRequest(to: s, network: n.rawValue, test: test, mainnet: !test)
}
if s.lowercased().hasPrefix("ethereum:") { return parseEIP681(String(s.dropFirst("ethereum:".count))) }
if isAddr(s) {
let net: String? = s.hasPrefix("0x") ? nil : WalletNetwork.allCases.first(where:{ WalletKeys.valid(s,network:$0) })?.rawValue
return PayRequest(to:s,network:net)
}
if s.lowercased().hasPrefix("ouryn:") { return parseOuryn(String(s.dropFirst("ouryn:".count))) }
guard s.lowercased().hasPrefix("solana:") else { return nil }
/* … Solana Pay 해석 (모르는 spl-token은 받지 않음) */
}견적 전 이중 송금 차단.
private func prepare() {
guard let owner = store.st.address(net: network.rawValue) else { error = "지갑 주소 검증을 확인해주세요"; return }
// 같은 네트워크에 확정 안 된 송금이 있으면 새로 보내지 않음 (나중에 둘 다 처리돼 두 번 나가는 일 방지)
if let prior = store.st.tx.first(where: { $0.type == .send && $0.inFlight && $0.network == network.rawValue }) {
error = prior.status == .delayed
? "이 네트워크의 이전 송금이 오래 확인되지 않고 있어요. 그 송금이 처리되거나 실패로 확인된 뒤에 보낼 수 있어요"
: "이 네트워크에서 이전 송금을 확인 중이에요"
return
}
/* … 체인별 quote() 호출 */
}서명 직전 재확인. EVM은 chain ID를 명시한 EIP-155 서명을 씁니다.
static func signEVM(_ quote:TransferQuote) throws -> SignedTransfer {
guard [.ethereum,.mantle,.avalanche].contains(quote.network), quote.expires > Date(),
let wallet = signingWallet(), WalletKeys.signingAddress(wallet,.ethereum).lowercased() == quote.owner.lowercased(),
quote.draft["chainID"] as? Int == quote.network.chainID,
/* … nonce · gasPrice · gasLimit · amount를 바이트로 변환 */
else { throw TransferError.invalid("확인이 만료됐거나 지갑을 열 수 없어요") }
let input = EthereumSigningInput.with {
$0.chainID = chainData; $0.nonce = nonceData; $0.gasPrice = priceData; $0.gasLimit = gasData
$0.toAddress = quote.network.contract(quote.asset) ?? quote.recipient
$0.privateKey = WalletKeys.signingKey(wallet,.ethereum).data
/* … ERC-20이면 erc20Transfer, 아니면 transfer */
}
let output:EthereumSigningOutput = AnySigner.sign(input:input,coin:.ethereum)
guard output.error == .ok, !output.encoded.isEmpty else { throw TransferError.invalid("서명 검증에 실패했어요") }
let id = "0x" + Hash.keccak256(data:output.encoded).hexString
return SignedTransfer(id:id,payload:["encoded":"0x" + output.encoded.hexString])
}독립 리뷰에서 송금 주변 문제 여섯 개가 나왔고 모두 고쳤습니다.
- SOL 수수료 확인에서 뺄셈이 넘칠 수 있었음 → UInt64 빼기로 고침
- 만료된 미확정 거래가 다음 송금을 계속 막음 → 체인별 만료 규칙(위 표)
- 재전파 중 지갑이 바뀌는 경우 → 현재 지갑에만 재시도 권한
- 묶음으로 받은 입금 중복 → 정확한 중복 제거
- 카르다노 선택 입력의 수량 보존 → 이전 거래 CBOR 원문으로 확인
- 키 교체 중 기존 키 손실 가능 → 안전한 Keychain 갱신
공개된 Wallet Core 서명 벡터(Tron·Ethereum EIP-155·Solana)와 카르다노 공개 거래의 CBOR 해시로 서명·직렬화가 맞는지 확인했습니다. check_transfer.swift는 트론 초안의 받는 주소·보내는 주소·수량·만료가 하나라도 다르면 거부하는지 검사합니다.
보안 설계
키는 이 기기에만 두고, 서명할 때는 Face ID를 통과해야만 꺼낼 수 있게 했습니다. 12단어가 화면·클립보드·키보드로 새는 길도 막았습니다. 서버가 없으므로 키·PIN은 어디로도 나가지 않습니다.
| 영역 | 설계 | 이유 |
|---|---|---|
| 키 보관 | 12단어만 Keychain에 저장. Face ID 등록 기기는 .biometryAny + WhenPasscodeSetThisDeviceOnly | iCloud 키체인·기기 백업으로 나가지 않고, 아이폰 암호로는 대신 열 수 없다 |
| 서명 권한 | 얼굴 등록 그대로면 Face ID만, 등록이 바뀌었으면 앱 PIN 먼저, PIN을 10번 넘게 틀리면 12단어 직접 입력만 | 누군가 자기 얼굴을 추가 등록한 경우를 따로 다룬다 |
| 서명 중 메모리 | 서명에 쓰는 지갑 객체는 withSigner 안에서만 있고 끝나면 비움 | Keychain을 조용히 읽어 서명하는 길이 없다 |
| 키 저장 방식 옮기기 | 임시 사본을 먼저 쓰고 원본을 바꾼 뒤 사본 삭제 | 옮기다 실패해도 키가 사라지지 않는다 |
| PIN | salt + PBKDF2-SHA256 10만 회 해시, 실패 횟수에 따른 대기, 약한 PIN 781개 차단 | 처음에는 PIN이 평문이었고 횟수 제한이 없었다 |
| 재설치 | "이 폰에 있던 지갑 열기"는 예전 PIN을 묻고, 잊었으면 12단어로 확인 | Keychain은 앱을 지워도 남아서, 재설치가 PIN 우회로가 될 수 있었다 |
| 12단어 화면 | 퀴즈 오답을 BIP39 2048단어에서 뽑음, 가림 막대 고정 폭, 녹화·미러링 중 숨김, 복사·공유 없음 | 예전 퀴즈는 오답을 내 12단어에서 골라, 퀴즈 화면만 봐도 12단어 중 6개가 보였다 |
| 주소 자체 검사 | 시작할 때 공개 테스트 문구로 주소를 만들어 다른 구현으로 구한 정답과 비교 | 파생 경로가 틀린 주소로 돈을 받으면 되찾기 어렵다 |
| 연결된 체인 확인 | EVM은 eth_chainId, Solana는 genesis 해시를 등록된 값과 비교 | 잘못된 RPC에 붙어 다른 체인에 서명하는 것을 막는다 |
| 서버가 만든 거래 | 트론 초안을 서명 전에 주소·수량·계약·만료와 대조 | 공개 서버가 돌려준 데이터를 그대로 서명하지 않는다 |
보내기 전 본인 확인 단계와 Keychain 조건.
/// - 얼굴 등록 그대로: Face ID 만
/// - 얼굴 등록이 바뀜 또는 생체인증 없는 항목: 앱 비밀번호 먼저 (그다음 Face ID)
/// - 그 상태에서 비밀번호를 10번 넘게 틀림: 12단어만
static func sendGate(wallet: String) -> SendGate {
let bio = isBiometric(wallet)
let changed = bio && Biometry.changed()
if PinVault.hardLocked && (changed || !bio) { return .words }
if changed || !bio { return .pin }
return .faceID
}
private static func bioAccess() -> SecAccessControl? {
SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryAny, nil)
}
static func withSigner<T>(_ w: HDWallet, _ body: () throws -> T) rethrows -> T {
signer = w
defer { signer = nil }
return try body()
}주소 자체 검사. 공개 테스트 문구로 만든 주소를 다른 구현(bip_utils)으로 미리 구한 정답과 비교하고, 틀린 네트워크는 주소를 보여 주지 않습니다.
private static let testWords = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
private static let expected = ChainAddrs(
solana: "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk",
ethereum: "0x9858EfFD232B4033E47d90003D41EC34EcaEda94",
bitcoin: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
cardano: "addr1qy8ac7qq…afdhv")
static let selfTest: [String: Bool] = {
guard let w = HDWallet(mnemonic: testWords, passphrase: "") else { /* 전부 실패 */ }
let a = addrs(w)
return [
"솔라나": a.solana == expected.solana,
"이더리움": a.ethereum == expected.ethereum,
"비트코인": a.bitcoin == expected.bitcoin,
"트론": a.tron == "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
/* … 카르다노, 맨틀, Avalanche, 테스트넷 */
]
}()Face ID를 못 쓰는 상황(얼굴 미등록, 얼굴 등록 변경)에서도 돈을 보낼 길이 남아야 했습니다. 그래서 보안 수준을 낮추는 대신 "앱 PIN 추가 → 12단어 직접 입력" 순서로 더 무거운 확인을 요구하도록 했습니다.
check_pin.py가 실제 PinVault 코드를 컴파일해 약한 PIN 규칙(1만 개 중 781개 차단)을, check_quiz.py가 백업 퀴즈를 무작위 1,200회 검사합니다.
검증 방법
사용자 복구 문구나 실제 자금 없이, 공개된 정답(테스트 벡터)으로 확인할 수 있는 것부터 확인했습니다. 검사 코드는 테스트용 복사본이 아니라 실제 앱 코드를 컴파일해 씁니다.
| 방법 | 무엇을 확인하나 |
|---|---|
| 시뮬레이터 진단 34개 | 7개 네트워크 주소 파생 벡터, 주소 검증·QR 왕복, 공개 서명 벡터, 카르다노 CBOR, 7개 공개 메인넷 API 연결 |
| Swift 실행 파일 4개 | 자산 레지스트리·정밀 금액, 트론 초안 검증, 비트코인 원문 출력, 카르다노 CBOR |
| Python 회귀 검사 5개 | QR 스캔 상태 전환, 시작 단계, 모달 교체, 백업 퀴즈, 약한 PIN 규칙 |
결과
실기기로 확인한 것은 원형 QR 금액 요청이고, 송금은 공개 테스트 벡터와 시뮬레이터 진단까지 확인했습니다.
| 증거 | 내용 |
|---|---|
| 원형 QR 실기기 확인 | 폰 두 대로 금액 요청 → 스캔 → 받는 사람·자산·금액 자동 입력 (2026.09.23) |
| 시뮬레이터 진단 | 34개 통과, 실패 0 (2026.10.02) |
| 빌드 | 앱 + 위젯 시뮬레이터 빌드 성공 (2026.10.02) |
| 규모 | 7개 네트워크, 16개 자산 항목, Swift 약 14,700줄 |
한계와 다음 단계
가장 큰 한계는 실제 자금으로 끝까지 보내 본 기록이 아직 없다는 점입니다.
- 실기기 소액 입금·송금 검증 전이고, 보안 감사도 받지 않았습니다.
- 체인 데이터는 공개 서버에 의존합니다. 풀 노드 수준의 독립 검증은 아닙니다.
- 백업은 종이에 적은 12단어뿐입니다.
- 받은 거래는 트론 USDT 최근 50건만 가져오고 나머지는 잔액 증가로 확인합니다.
- EVM은 EIP-155 legacy 서명이고, EIP-1559 수수료 모델은 아직 적용하지 않았습니다.
- iOS만 있습니다.
- 테스트 통장으로 7개 테스트넷 전송을 확인하고 해시를 기록합니다.
- 네트워크·자산 레지스트리, 거래 검증, 미확정 거래 추적을 Rust 모듈로 분리합니다. 키 파생·서명은 Wallet Core, UI는 Swift에 남깁니다.
- 12단어를 보여 주지 않는 iCloud 암호화 백업(Argon2id → AES-256-GCM).
- 0x 주소를 세 EVM 네트워크에 한 번에 저장하기, 보내기 화면 안 네트워크 칩 줄.
- 출시 전 유료 RPC나 자체 노드 검토.
배운 점
- 지갑의 UX 결정은 대부분 보안 결정이다. 0x 주소의 기본 네트워크를 없앤 것, 테스트넷을 토글이 아닌 통장으로 둔 것, 확인 화면에 주소 전체를 둔 것은 모두 돈을 잃는 경로 하나를 막는 결정이었다.
- "실패"는 네트워크가 정한다. 응답이 없다고 실패로 보면 사용자가 다시 보내 두 번 나간다. 체인마다 "이 거래는 더는 들어갈 수 없다"를 판단하는 기준이 다르다.
- 정답이 있는 것부터 검증한다. 주소 파생·서명은 공개 테스트 벡터로, 금액은 정수 문자열로 다루면 실제 돈을 쓰기 전에 상당 부분을 확인할 수 있다.