← 프로젝트 목록
개발 중

Ouryn

은행 앱처럼 쉽게 시작하는 멀티체인 자기수탁 지갑.

체인에서 생기는 위험을 코인 용어 대신 화면 흐름으로 막아 주는 한국어 iOS 자기수탁 지갑입니다. 7개 네트워크를 지원하고, 송금은 기기 안에서 서명합니다.

기간
2026.09.18 – 진행 중
출처
개인 프로젝트
플랫폼
iOS · SwiftUI · Trust Wallet Core
범위
7개 네트워크 · 16개 자산
이 페이지의 목차

문제

코인을 거래소 밖으로 처음 꺼내려는 사람은 첫 단계부터 막힙니다. 지갑을 만들 때 받는 12단어가 무엇인지, 지갑 주소가 무엇인지 모르는 상태에서, 거래소에서 내 지갑으로 출금하는 방법과 첫 송금을 혼자 해내야 합니다. 첫 송금은 실수하면 돈을 되찾기 어렵기 때문에, 이 과정이 입문의 가장 큰 장벽이 됩니다.

여기에 체인 자체의 위험이 겹칩니다. 똑같은 USDT라도 트론·솔라나·이더리움에 따로 있고, 0x 주소는 이더리움·맨틀·Avalanche에서 모양이 같아서 주소만으로는 어느 네트워크인지 알 수 없습니다. 네트워크를 잘못 고르면 돈을 돌려받기 어렵고, 보낸 뒤 응답이 끊기면 다시 보내다가 두 번 나갈 수도 있습니다.

기존 지갑은 체인 용어와 주소 문자열을 그대로 보여 주고 판단을 사용자에게 맡깁니다. 은행·간편송금 앱에만 익숙한 사람에게는 이 과정을 넘기 어렵습니다. 그래서 지갑 만들기부터 첫 출금, 첫 송금까지를 사용자의 지식이 아니라 UX로 풀어야 할 문제로 봤습니다.

해결 개요

사용자는 은행 앱에서 쓰던 말("내 돈 · 보내기 · 받기", "통장", "내역")로 앱을 쓰고, 체인별 차이는 앱이 안에서 처리합니다. 네트워크를 추측할 수 없는 순간(0x 주소, 테스트넷 주소)에만 사용자에게 고르게 하고, 서명 전에는 받는 주소·수량·수수료 상한을 한 화면에 보여 줍니다. 전파한 거래는 해시로 끝까지 추적하고, 응답이 불확실해도 새 거래를 만들지 않습니다.

  1. 01주소 입력붙여넣기, 카메라 QR, 앨범 QR이 모두 같은 파서로 들어간다.
  2. 02네트워크 확정주소 모양과 체인 번호로 알아보고, 알 수 없는 0x 주소만 사용자가 고른다.
  3. 03견적 · 검증잔액, 수수료 상한, chain ID를 확인하고 확정 안 된 송금이 있으면 막는다.
  4. 04본인 확인 후 기기 내 서명Face ID를 통과해야 키를 꺼내고, 서명 직전에 견적과 주소를 다시 확인한다.
  5. 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개 자산 항목의 잔액·받기·보내기를 하나의 흐름에 올렸습니다. 지원 네트워크와 자산 범위는 제가 정했습니다.

네트워크자산송금에서 다른 점
TronUSDT(TRC-20), TRX계정 활성화 필요. Energy·Bandwidth 기반 수수료, fee_limit 필수
SolanaUSDC, USDT, SOL최신 blockhash가 만료됨. 서명 후 사전 검사
EthereumUSDC, USDT, ETHchain ID, nonce. ERC-20은 컨트랙트 호출
MantleMNT, USDT0EVM 엔진 공유, chain ID 5000
Avalanche C-ChainUSDC, USDT, AVAXEVM 엔진 공유, chain ID 43114
BitcoinBTCUTXO 선택, 거스름돈
CardanoADA, NIGHTUTXO, 토큰에 최소 ADA를 함께 보냄, CBOR
설계 결정

세 안을 놓고 세 번째를 골랐습니다.

  1. 기존 문자열 맵에 체인만 추가: 구현은 짧지만 USDT 잔액·수수료·입금 추적이 체인 사이에서 섞인다.
  2. 외부 지갑에 서명 위임: 송금 구현은 줄지만 Ouryn 자체 지갑 경험이 아니다.
  3. 네트워크·자산 식별자 + 체인별 어댑터 (채택): 키는 기기 안에 두고, 체인마다 다른 부분만 어댑터가 맡는다.

그 위에 네 가지 규칙을 두었습니다.

  • 잔액의 기본 단위는 (network, asset, account)입니다. 홈에는 USDT 전체를 합산해 보여 주지만, 보낼 수 있는 금액은 고른 네트워크의 잔액뿐입니다.
  • 맨틀 USDT0는 발행사 문서로 계약을 확인해 USDT와 다른 자산으로 표시합니다. 계약을 확인하지 못한 토큰은 넣지 않았습니다.
  • 금액은 정수 문자열(최소 단위)로 다루고 계산에 Double을 쓰지 않습니다.
  • EVM 세 네트워크는 주소가 같아도 잔액·송금 상태는 따로입니다. RPC에 붙을 때마다 eth_chainId를 등록된 값과 비교합니다.

네트워크 레지스트리. 체인별 차이(chain ID, 수수료 자산, 토큰 계약, RPC)를 한 enum에 모았고, 테스트 통장이면 같은 자리에서 테스트넷 값이 나옵니다.

ios/Ouryn/Core/Networks.swift
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입니다. 그래서 확인 화면·본인 확인·기록·추적은 체인을 몰라도 됩니다.

ios/Ouryn/Core/Transfers.swift
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"를 자산 자릿수에 맞는 정수 문자열로 바꿉니다. 자릿수보다 긴 소수는 버리지 않고 거부합니다.

ios/Ouryn/Core/Networks.swift — ExactAmount.parse
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번 계정을 조회해 기록이 있는 번호까지 가져옵니다.

송금 흐름: 주소 입력부터 확정까지

만든 것

보내기 화면 하나가 홈 보내기, 코인 상세, 주소록, "다시 보내기"의 공통 경로입니다. 모든 체인을 한꺼번에 열지 않고, 체인별 구현과 검증이 끝난 것만 열기로 했습니다.

  1. 주소 입력. 붙여넣기·카메라 QR·앨범 QR이 모두 같은 파서 parsePay로 들어갑니다. 지원하지 않는 형식은 "지원하는 지갑 주소 QR이 아니에요"로 멈춥니다.
  2. 네트워크 확정. 비트코인·카르다노는 주소 모양으로, EVM은 EIP-681의 체인 번호로 알아봅니다. 체인 번호 없는 0x 주소는 사용자가 고릅니다.
  3. 견적·검증. 같은 네트워크에 확정 안 된 송금이 있으면 막습니다. EVM은 eth_estimateGas의 1.2배, 트론 USDT는 예상 Energy의 1.3배를 수수료 상한으로 잡습니다.
  4. 확인 화면 "이렇게 보낼게요". 코인과 네트워크, 받는 주소 전체, 수량, 수수료 상한을 보여 줍니다. 견적은 45초 뒤 만료됩니다.
  5. 본인 확인과 서명. Face ID를 통과해야 Keychain에서 12단어가 나오고, 서명하는 동안만 메모리에 둡니다. 서명 직전에 견적 만료, 서명 키의 주소, chain ID를 다시 확인합니다.
  6. 기록 후 전파. 서명된 거래의 해시를 먼저 저장하고 전파합니다. 응답이 불확실하면 "거래 해시로 확인 중이며 다시 보내지 마세요"를 보여 줍니다.
  7. 확정 추적. 확정 전에는 같은 서명 거래만 다시 전파하고, 새 거래를 자동으로 만들지 않습니다.
설계 결정

서버 장애나 응답 유실은 실패로 보지 않습니다. 네트워크가 그 거래를 더는 받을 수 없다는 것이 확인될 때만 실패로 확정합니다. 사용자가 "안 간 줄 알고" 다시 보내 두 번 나가는 일을 막기 위해서이고, 그 기준은 체인마다 다릅니다.

네트워크실패로 확정하는 조건
Solana거래가 없고, finalized 블록 높이가 거래의 유효 높이를 넘음
Cardano확인 수가 없고, 현재 슬롯이 유효 슬롯 + 300을 넘음
EVM확정된 nonce가 이 거래의 nonce를 넘었는데 이 거래는 영수증도 거래 자체도 없음
Bitcoin쓰려던 입력(txid:vout)을 다른 거래가 먼저 씀
Tron거래가 없고, 확정 블록 시각이 만료 + 60초를 넘음

72시간 넘게 확정되지 않으면 "지연"으로 표시하고, 다시 전파하는 간격을 30초에서 10분으로 늘립니다.

입력 파서. 0x 주소에 network: nil을 돌려주는 줄이 "추측하지 않기" 결정입니다.

ios/Ouryn/Core/Pay.swift — parsePay
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은 받지 않음) */
}

견적 전 이중 송금 차단.

ios/Ouryn/Screens/TransferFlow.swift — prepare()
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 서명을 씁니다.

ios/Ouryn/Core/EVMTransfer.swift — signEVM
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 + WhenPasscodeSetThisDeviceOnlyiCloud 키체인·기기 백업으로 나가지 않고, 아이폰 암호로는 대신 열 수 없다
서명 권한얼굴 등록 그대로면 Face ID만, 등록이 바뀌었으면 앱 PIN 먼저, PIN을 10번 넘게 틀리면 12단어 직접 입력만누군가 자기 얼굴을 추가 등록한 경우를 따로 다룬다
서명 중 메모리서명에 쓰는 지갑 객체는 withSigner 안에서만 있고 끝나면 비움Keychain을 조용히 읽어 서명하는 길이 없다
키 저장 방식 옮기기임시 사본을 먼저 쓰고 원본을 바꾼 뒤 사본 삭제옮기다 실패해도 키가 사라지지 않는다
PINsalt + PBKDF2-SHA256 10만 회 해시, 실패 횟수에 따른 대기, 약한 PIN 781개 차단처음에는 PIN이 평문이었고 횟수 제한이 없었다
재설치"이 폰에 있던 지갑 열기"는 예전 PIN을 묻고, 잊었으면 12단어로 확인Keychain은 앱을 지워도 남아서, 재설치가 PIN 우회로가 될 수 있었다
12단어 화면퀴즈 오답을 BIP39 2048단어에서 뽑음, 가림 막대 고정 폭, 녹화·미러링 중 숨김, 복사·공유 없음예전 퀴즈는 오답을 내 12단어에서 골라, 퀴즈 화면만 봐도 12단어 중 6개가 보였다
주소 자체 검사시작할 때 공개 테스트 문구로 주소를 만들어 다른 구현으로 구한 정답과 비교파생 경로가 틀린 주소로 돈을 받으면 되찾기 어렵다
연결된 체인 확인EVM은 eth_chainId, Solana는 genesis 해시를 등록된 값과 비교잘못된 RPC에 붙어 다른 체인에 서명하는 것을 막는다
서버가 만든 거래트론 초안을 서명 전에 주소·수량·계약·만료와 대조공개 서버가 돌려준 데이터를 그대로 서명하지 않는다

보내기 전 본인 확인 단계와 Keychain 조건.

ios/Ouryn/Core/Keys.swift — sendGate · withSigner
/// - 얼굴 등록 그대로: 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)으로 미리 구한 정답과 비교하고, 틀린 네트워크는 주소를 보여 주지 않습니다.

ios/Ouryn/Core/Keys.swift — selfTest
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만 있습니다.
다음 단계
  1. 테스트 통장으로 7개 테스트넷 전송을 확인하고 해시를 기록합니다.
  2. 네트워크·자산 레지스트리, 거래 검증, 미확정 거래 추적을 Rust 모듈로 분리합니다. 키 파생·서명은 Wallet Core, UI는 Swift에 남깁니다.
  3. 12단어를 보여 주지 않는 iCloud 암호화 백업(Argon2id → AES-256-GCM).
  4. 0x 주소를 세 EVM 네트워크에 한 번에 저장하기, 보내기 화면 안 네트워크 칩 줄.
  5. 출시 전 유료 RPC나 자체 노드 검토.

배운 점

  1. 지갑의 UX 결정은 대부분 보안 결정이다. 0x 주소의 기본 네트워크를 없앤 것, 테스트넷을 토글이 아닌 통장으로 둔 것, 확인 화면에 주소 전체를 둔 것은 모두 돈을 잃는 경로 하나를 막는 결정이었다.
  2. "실패"는 네트워크가 정한다. 응답이 없다고 실패로 보면 사용자가 다시 보내 두 번 나간다. 체인마다 "이 거래는 더는 들어갈 수 없다"를 판단하는 기준이 다르다.
  3. 정답이 있는 것부터 검증한다. 주소 파생·서명은 공개 테스트 벡터로, 금액은 정수 문자열로 다루면 실제 돈을 쓰기 전에 상당 부분을 확인할 수 있다.

개발 기록