고객의 지갑 주소가 필요하다!
사용자가 자신의 NFT를 확인하기 위해서는 자신의 지갑 주소가 필요합니다. 또, 관리자가 사용자에게 NFT를 발급해주기 위해서 트랜잭션에 서명을 해야 하므로 관리자 지갑의 Private Key
또한 필요하겠죠. 그렇다면 프론트엔드에서 관리자 지갑의 Private Key
와 고객의 지갑 주소를 알기 위해서는 어떻게 해야 할까요?
사실 관리자의 Private Key
는 매우 민감한 비밀 정보이기 때문에 조금은 불편하더라도 일단 관리자에게 직접 입력 받도록 하는 것이 가장 쉽고 안전한 방법일 것입니다. 그러나 일반 고객들에게 그 길고 긴 지갑 주소를 직접 입력하도록 한다면 굉장히 좋지 않은 유저 경험일텐데요. 그래서 카카오톡에서 제공하는 Klip 서비스를 도입하기로 했습니다.
Klip이란?
Klip이란 카카오톡에서 제공하는 클레이튼 기반 개인 디지털 지갑 서비스인데요, 스마트폰에 카카오톡만 설치되어 있으면 별도의 설치 없이 이용할 수 있다는 점이 가장 큰 장점입니다.
고객의 지갑 주소를 받는 방법을 간단하게 설명하면 다음과 같습니다.
- [ Prepare 단계 ] 프론트엔드에서 Klip Rest API로 request를 전송하여
request_key
를 받습니다. API 엔드포인트와 REST API 호출 시 Body 부분에 넣어주어야 할 데이터 형식은 Klip API Specification을 참조해 주세요. - [ Request 단계 ] 1에서 받은
request_key
를 활용하여 아래 URL을 유저에게 제공합니다.
<https://klipwallet.com/?target=/a2a?request_key={requestKey}> - 유저가 모바일로 위 링크에 접속하면 주소를 제공할 것인지 묻는 화면이 나오게 됩니다.
- [ Result 단계 ] 프론트엔드에서 Klip API로
request_key
에 대한 지갑 주소 정보가 제공되었는지 확인합니다. - 만약 고객이 정보 제공을 허용했다면, 다음과 같이 결과를 얻을 수 있습니다
{
"expiration_time": 1653958682,
"request_key": "abcabc...",
"status": "completed",
"result": {
"klaytn_address": "0xabcabc123123..."
}
}
이렇게 고객의 입장에서는 모바일 링크에 접속해서 주소 정보 제공에 동의하는 과정만으로도 쉽게 자신의 지갑 주소 정보를 넘겨줄 수 있습니다!
저희 수호 카페 NFT 프로젝트에서 고객의 지갑 주소가 필요한 케이스는 크게 두 가지로 나눌 수 있습니다.
예) 관리자 기기로 고객에게 재료를 발급해 주고자 할 때
2. 고객이 자신의 정보를 열람하고자 하는 경우
예) 고객이 자신이 가진 커피 토큰 목록을 보고자 할 때
Request 단계에서 고객에게 URL을 제공할 때, 1번 케이스라면 매장 내 태블릿 PC에 QR코드를 띄운 후 고객에게 카메라 앱을 통해 QR 코드를 인식하여 지갑 주소 제공 요청을 처리하도록 할 수 있을 것입니다.
이 경우 QR 코드를 스캔한 유저는 먼저 Klip 홈페이지로 이동 후 '카카오톡'에서 이 페이지를 열겠습니까?
라는 메시지와 함께 카카오톡 앱 내의 Klip으로 이동시켜 줍니다.
반대로 2번 케이스라면 고객이 자신의 기기에 지갑 주소를 제공해야 하는 것인데요, 이 경우는 Deep Link
를 활용하면 훨씬 좋은 유저 경험을 이끌어낼 수 있습니다. 딥링크는 웹사이트나 스토어가 아닌 앱으로 직접 이동시켜주기 때문에 페이지를 이동하는 시간과 노력을 줄일 수 있습니다.
아래의 Deep Link
를 활용하면 유저가 웹 링크를 통해 인터넷 브라우저에 접속할 필요 없이 곧바로 카카오톡의 Klip으로 이동할 수 있게 됩니다.
// ios
kakaotalk://klipwallet/open?url=https://klipwallet.com/?target=/a2a?request_key={requestKey}
//android
intent://klipwallet/open?url=https://klipwallet.com/?target=/a2a?request_key={requestKey}#Intent;scheme=kakaotalk;package=com.kakao.talk;end
Klip Docs에 들어가면 더 다양한 Klip API 사용 방법을 확인하실 수 있습니다.
Klip 사용을 위한 커스텀 훅 만들기 (feat. useKlip)
카카오톡 Klip은 주소 제공(인증) 뿐 아니라 KLAY 전송, 토큰 전송, 카드 전송, 스마트 컨트랙트 실행 등 다양한 작업을 실행할 수 있습니다. 주소를 제공하는 경우를 제외한 나머지 작업들은 모두 클레이튼에 트랜잭션을 전송하는 작업으로, 트랜잭션을 전송하기 위해서는 사용자의 서명이 필수적입니다. 사용자의 서명을 받는 방법은 주소를 제공받는 것과 같이 Prepare - Request - Result의 3단계에 거쳐 진행됩니다.
매번 고객의 지갑 주소를 제공받고 트랜잭션을 전송하기 위해 3단계의 과정을 하나부터 열까지 코딩하는 것은 매우 비효율적이기 때문에, 짧은 코드만으로 3단계의 과정을 해낼 수 있는 커스텀 훅 useKlip을 만들어 보았습니다.
- HTTP 통신 라이브러리는 axios를 사용하였습니다.
- 수호 카페 프로젝트에서는 Klip의 주소 제공과 스마트 컨트랙트 실행 작업만을 사용할 예정입니다.
커스텀 훅 개발에 앞서 axios 인스턴스를 생성해 줍니다.
// useKlip.ts
const klipApi = axios.create({
baseURL:'<https://a2a-api.klipwallet.com/v2/a2a/>'
})
useKlip
에서는 Prepare 단계 실행 함수와 request key
등의 최소한의 함수와 변수만을 제공하고, 고객이 정보 제공에 동의하였는지 주기적으로 체크하거나 딥링크에 자동적으로 접속하도록 하는 등의 작업은 훅 내에서 implicit하게 실행하고자 합니다.
Prepare 단계에서 주소를 제공할 때와 스마트 컨트랙트를 실행할 때 REST API의 body에 담아야 할 정보도 다르고 API 호출시 얻는 데이터의 형식도 다르기 때문에 Typescript의 인터페이스를 먼저 지정하여 사용성을 높여주었습니다.
먼저 Prepare 단계에서 API를 호출할 때 사용할 인터페이스입니다.
/// klip-prepare.interface.ts
export type RequestType = 'auth' | 'execute_contract'
// 주소 제공(인증)에 대한 Prepare 요청 시 필요한 데이터
export interface KlipRequestAuth {
type: 'auth'
}
// 컨트랙트 실행에 대한 Prepare 요청 시 필요한 데이터
export interface KlipRequestExecuteContract {
type: 'execute_contract'
transaction: KlipExecuteContractTransaction
}
// 컨트랙트 실행을 요청할 때는 아래 데이터를 같이 보내주어야 한다.
export interface KlipExecuteContractTransaction {
from: string
to: string
value: string, // 단위는 peb.
abi: string, // abi를 string 형식으로 변환해 주어야 함.
params: any[] | string // params 또한 string 형식으로 변환해 주어야 함.
}
// Klip에 request를 요청할 때는
// KlipRequestAuth나 KlipRequestExecuteContract의 형태만 가능하다.
export type KlipRequest = KlipRequestAuth | KlipRequestExecuteContract
// Prepare 단계 API 호출 시 리턴 데이터
export interface KlipRequestResult {
expiration_time: number
request_key: string
status: 'prepared' | 'requested' | 'completed' | 'canceled' | 'error'
}
다음은 Result 단계에서 고객이 제공한 정보를 얻는 API를 호출할 때 사용할 인터페이스입니다.
주소 제공(Auth) 요청 시에는 유저의 지갑 주소만 리턴되는 반면, 인증을 제외한 나머지 작업들은 모두 트랜잭션을 전송하기 때문에 트랜잭션 해시(tx_hash
)와 트랜잭션의 현재 상태를 리턴합니다.
/// klip-result.interface.ts
export interface KlipResult<T = KlipAuth | KlipTransaction> {
expiration_time: number
request_key: string
status: 'prepared' | 'requested' | 'completed' | 'canceled' | 'error'
result: T
}
export interface KlipAuth {
klaytn_address: string
}
export interface KlipTransaction {
tx_hash: string,
status: 'pending' | 'success'
}
이제 본격적으로 useKlip
커스텀 훅을 작성해 보겠습니다!
// Typescript의 제네릭을 사용하여 어떤 타입의 요청을 수행할 것인지 명시해줍니다.
function useKlip<T extends KlipAuth | KlipTransaction>(
onResult: (result:T) => void | Promise<void>
)
{
const [requestKey, setRequestKey] = useState<string>("")
const confirmUrl = useMemo(() => toConfirmUrl(requestKey), [requestKey])
// 고객이 request를 승인했는지 묻는 API를 주기적으로 호출하기 위함
let intervalId;
// autoOpenInMobile=true이면 모바일 기기에서 OS를 자동으로 인식하여 Deep Link로 연결해준다.
const prepare = async (request: KlipRequest, autoOpenInMobile: boolean) => {
// stringify transaction obj
// [참조]: <https://docs.klipwallet.com/tutorial/tutorial-a2a-rest-api#case-5-execute-contract>
if(request.type !== 'auth'){
request.transaction.params = JSON.stringify(request.transaction.params);
}
const request_key = await klipApi.post<KlipRequestResult>('prepare', {
bapp: {
name: "SOOHO Café",
},
...request
}).then(({data:{request_key}}) => {
setRequestKey(request_key)
return request_key
}).catch(e => {
alert("다시 시도해 주세요.")
return;
})
if(!request_key)
return;
const confirmUrl = `https://klipwallet.com/?target=/a2a?request_key=${requestKey}`;
if(autoOpenInMobile){
const os = getOS()
if(os === 'iOS'){
window.location.href = `kakaotalk://klipwallet/open?url=${confirmUrl}`;
}
else if(os === 'Android'){
window.location.href = `intent://klipwallet/open?url=${confirmUrl}#Intent;scheme=kakaotalk;package=com.kakao.talk;end`;
}
}
// check if confirmed
intervalId = setInterval(() => getResult(request_key), 500)
return;
},[])
const getResult = async (request_key: string) => {
return klipApi.get<KlipResult<T>>('result', {
params: {request_key}
}).then(async ({data}) => {
if(data.status === 'completed'){
clearInterval(intervalId)
await onResult(data.result)
}
})
}
const clearValidate = () => clearInterval(intervalId)
return {
requestKey,
confirmUrl,
prepare,
clearValidate
}
}
useKlip
커스텀 훅은 prepare
함수만 실행시켜주면 그 다음 단계인 request
단계의 URL을 알아서 생성해주고, 모바일인 경우에는 알아서 인증 화면에 도달할 수 있도록 해 줍니다. 유저가 request를 승인했는지 500ms마다 한 번씩 주기적으로 확인하는 작업까지도 훅 내부에서 알아서 실행해 줍니다.
따라서 개발자는 prepare
함수 실행 한 번으로 유저가 request를 승인하는 과정과 result를 얻어오는 과정까지 모두 실행할 수 있기 때문에 개발자에게도 좋은 경험을 줄 수 있게 되었습니다😙
간단하게 useKlip
의 사용 예시를 살펴 보겠습니다. 먼저 useKlip
훅을 호출해주세요!
// 유저가 요청을 승인했을 때 실행될 함수
const onKlipResult = (result:KlipAuth) => {
setAddress(result.klaytn_address)
}
const { confirmUrl, prepare, clearValidate } = useKlip<KlipAuth>(onKlipResult)
저는 페이지에 접속하면 바로 request가 요청되고, 모바일 환경에서 자동으로 카카오톡의 Klip이 실행되도록 해보았습니다.
요청이 정상적으로 승인되지 않은 경우 500ms마다 getResult
함수를 호출하던 것을 중지하기 위해 페이지를 떠날 때 clearValidate
함수를 실행하도록 하였습니다.
useEffect(() => {
prepare({type: 'auth'}, true);
return clearValidate
},[])
이제 단 몇 줄만으로도 Klip의 Prepare - Request - Result 단계를 손쉽게 사용할 수 있게 되었습니다!
(참고) * Stack Overflow - 현재 유저의 OS를 얻는 getOS() 함수
관리자의 Private Key 입력받기
지금까지 Klip을 사용해서 유저의 주소를 얻어오고, 유저가 Klip에서 트랜잭션을 처리할 수 있도록 했던 것들은 모두 유저에게 주소나 Private key
를 직접 입력하게 하는 것이 매우 좋지 않은 유저 경험이기 때문이었습니다.
그러나 관리자의 Private Key
는 매우 민감한 비밀 정보이기 때문에 조금은 불편하더라도 일단 관리자에게 직접 입력 받도록 하였습니다.
const ownerSign = (pk: string) => {
try {
const keyring = caver.wallet.keyring.createFromPrivateKey(pk)
if(keyring.address !== DEPLOYER_ADDERSS)
return false;
caver.wallet.add(keyring)
return true;
} catch (error) {
return false;
}
}
관리자의 Private key
를 가지고 keyring
을 생성한 후, keyring
을 caver.wallet
에 등록해 주기만 하면 됩니다! 매장 내 태블릿 PC 등에 한 번 keyring을 등록해 놓기만 하면, 이후에 관리자 계정으로 재료를 발급해 주거나 커피 토큰을 실제 커피로 교환해줄 때 Klip을 사용할 필요 없이 트랜잭션에 바로 서명할 수 있습니다. 🙌
