승인 단계
WalletKit은 세션 프로토콜을 처리하지만 사용자 동의는 대신하지 않습니다. connect()는 두 지점에서 지갑 앱의 승인을 받으며, 이 승인 UI는 setApprovalHandler()로 연결합니다.
세션 전체 흐름은 세션 연결, 승인 후 실제 서명은 서명과 개인키 처리에서 다룹니다.
승인 핸들러 등록
지갑 앱은 연결·서명 승인 UI를 하나의 suspend 콜백으로 등록합니다. 콜백은 요청 종류에 따라 분기해 사용자 확인 결과를 반환합니다.
- Kotlin (Android · JVM)
- Swift (iOS)
wallet.setApprovalHandler { request ->
when (request) {
is ApprovalRequest.Connection ->
if (userAllows(request.info)) ApprovalDecision.APPROVE
else ApprovalDecision.reject("user declined")
is ApprovalRequest.Sign ->
if (userConfirms(request.info)) ApprovalDecision.APPROVE
else ApprovalDecision.reject("user declined")
}
}
// Kotlin의 suspend 함수형 파라미터는 Swift 클로저가 아니라
// KotlinSuspendFunction1 프로토콜로 노출됩니다 — NSObject를 상속한 클래스로 감싸 등록합니다.
final class ApprovalHandler: NSObject, KotlinSuspendFunction1 {
func invoke(p1: Any?) async throws -> Any? {
switch p1 {
case let request as ApprovalRequestConnection:
return await userAllows(request.info)
? ApprovalDecision.companion.APPROVE
: ApprovalDecision.companion.reject(reason: "user declined")
case let request as ApprovalRequestSign:
return await userConfirms(request.info)
? ApprovalDecision.companion.APPROVE
: ApprovalDecision.companion.reject(reason: "user declined")
default:
return ApprovalDecision.companion.reject(reason: "unknown request")
}
}
}
wallet.setApprovalHandler(handler: ApprovalHandler())
콜백이 suspend이므로 실제 확인 화면을 띄우고 사용자 입력을 기다리는 동안 자연스럽게 일시 중단할 수 있습니다. Swift에서는 invoke가 async이므로 withCheckedContinuation 등으로 사용자 입력을 기다렸다가 재개하면 됩니다.
핸들러를 등록하지 않으면 모든 연결과 서명 요청이 자동 승인됩니다. 이 기본값은 UI 없는 테스트와 데모를 위한 것입니다. 운영 지갑은 반드시 사용자 확인 UI를 거치는 핸들러를 등록하세요.
연결 승인과 서명 승인
| 승인 단계 | 시점 | 거절 시 동작 |
|---|---|---|
ApprovalRequest.Connection | dApp 연결 제안 수신 후, 세션 연결 응답 전 | 세션을 연결하지 않고 approved = false로 반환 |
ApprovalRequest.Sign | 서명 직전 | dApp에 EIP-1193 4001 오류로 응답하고 approved = false로 반환 |
연결 승인과 서명 승인은 독립된 결정입니다. 연결 승인은 이후 서명 승인을 포함하지 않으며, 각 요청은 별도 화면에서 다시 확인합니다.
ApprovalRequest와 ApprovalDecision
- Kotlin (Android · JVM)
- Swift (iOS)
public sealed interface ApprovalRequest {
public data class Connection(val info: ConnectionRequestInfo) : ApprovalRequest
public data class Sign(val info: SignRequestInfo) : ApprovalRequest
}
public data class ApprovalDecision(val approved: Boolean, val reason: String? = null) {
public companion object {
public val APPROVE: ApprovalDecision
public fun reject(reason: String): ApprovalDecision
}
}
// sealed 계층은 평탄화된 클래스 이름으로 노출됩니다.
protocol ApprovalRequest {}
class ApprovalRequestConnection: ApprovalRequest {
var info: ConnectionRequestInfo { get }
}
class ApprovalRequestSign: ApprovalRequest {
var info: SignRequestInfo { get }
}
class ApprovalDecision {
init(approved: Bool, reason: String?)
var approved: Bool { get }
var reason: String? { get }
class var companion: ApprovalDecision.Companion { get }
class Companion {
var APPROVE: ApprovalDecision { get }
func reject(reason: String) -> ApprovalDecision
}
}
승인은 ApprovalDecision.APPROVE, 거절은 ApprovalDecision.reject(reason)으로 반환합니다. reason은 호출한 앱 코드에서 쓰기 위한 문자열입니다 — dApp에 전달되거나 라이브러리가 기록하지는 않으며, 서명 거절 시 dApp에는 고정 메시지와 4001 코드만 전송됩니다.
sealed 계층은 Swift에 평탄화된 클래스 이름으로 노출됩니다 — ApprovalRequest.Connection은 ApprovalRequestConnection, ApprovalRequest.Sign은 ApprovalRequestSign으로 캐스팅해 분기합니다. companion 멤버는 ApprovalDecision.companion.APPROVE·ApprovalDecision.companion.reject(reason:)으로 접근합니다.
연결 승인
연결 제안을 받으면 dApp이 밝힌 정보로 연결 요청을 구성해 표시합니다.
- Kotlin (Android · JVM)
- Swift (iOS)
public data class ConnectionRequestInfo(
val dappName: String,
val chains: List<String>,
val pairingTopic: String,
)
class ConnectionRequestInfo {
var dappName: String { get }
var chains: [String] { get }
var pairingTopic: String { get }
}
| 필드 | 표시 내용 |
|---|---|
dappName | proposer.metadata에 담긴 dApp 이름 |
chains | 요청된 CAIP-2 체인 목록 (예: eip155:11155111) |
pairingTopic | 페어링 토픽 |
dappName은 상대가 자기 자신을 밝힌 값이므로 신뢰할 수 있는 식별자가 아닙니다. 사용자가 판단할 수 있도록 이 값을 표시하고, 요청된 체인이 지갑이 지원하는 네트워크인지 확인합니다. 이후 세션 연결 단계에서는 사용자가 승인한 계정만 전송합니다.
서명 승인
서명 요청은 EIP-3009 eth_signTypedData_v4 요청을 디코딩한 표시용 정보로 전달됩니다.
- Kotlin (Android · JVM)
- Swift (iOS)
public data class SignRequestInfo(
val account: String,
val chainId: String,
val method: String,
val tokenName: String,
val verifyingContract: String,
val from: String,
val to: String,
val value: String,
val validAfter: String,
val validBefore: String,
val nonce: String,
)
class SignRequestInfo {
var account: String { get }
var chainId: String { get }
var method: String { get }
var tokenName: String { get }
var verifyingContract: String { get }
var from: String { get }
var to: String { get }
var value: String { get }
var validAfter: String { get }
var validBefore: String { get }
var nonce: String { get }
}
| 필드 | 표시·검증 포인트 |
|---|---|
account | 서명 계정 |
chainId | eip155:{n} 형식의 CAIP-2 체인 |
method | eth_signTypedData_v4 |
tokenName | EIP-712 domain.name |
verifyingContract | 서명 검증에 사용할 컨트랙트 주소 — 신뢰하는 주소인지 확인 |
from, to | 전송 당사자 |
value | 토큰의 최소 단위로 표현된 uint256 정수 |
validAfter, validBefore | 유효 기간 |
nonce | bytes32 랜덤 nonce |
value는 토큰의 최소 단위로 표현된 정수입니다. 사용자에게 표시할 때는 토큰의 소수 자릿수를 반영해 사람이 읽을 수 있는 금액으로 변환합니다. 승인 전에 도메인, 체인 ID, 서명 검증에 사용할 컨트랙트와 수취인·금액을 모두 사용자에게 보여주고 확인받습니다.
SignRequestInfo의 값은 dApp이 보낸 요청에서 디코딩된 것입니다. WalletKit은 내용의 정당성(수취인·금액·컨트랙트가 사용자가 의도한 것인지)을 판단하지 않습니다. 도메인·체인·컨트랙트·금액 검증과 피싱 방지는 지갑 앱의 승인 화면 책임입니다.
거절 처리
거절 결과는 승인 단계마다 다르게 전달됩니다.
- 연결 거절 — 세션을 수립하지 않고
WalletSessionResult를approved = false, 빈sessionTopic으로 반환합니다. 이때 dApp에는 별도의 거절 응답을 발행하지 않으므로, dApp은 자체 대기 시간이 지나야 거절을 인지합니다. - 서명 거절 — dApp에 EIP-1193
4001(사용자 거절) 오류를 응답한 뒤approved = false로 반환합니다. dApp은 이 코드를 사용자 거절로 해석합니다.
두 SDK의 오류 코드 대응 관계는 WalletKit 오류 처리에서 다룹니다.
다음 단계
- 서명과 개인키 처리 — 지갑 앱의
Signer구현과 개인키 처리 책임 - WalletKit API —
ApprovalRequest와ApprovalDecision의 전체 정의