Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Blockchain/EN

Link-Based USDT Transfers Without a Wallet
Link-Based USDT Transfers Without a Wallet

지갑 없이 링크로 USDT 보내는 송금 서비스

지갑도 가스도 없는 사람에게 링크 하나로 USDT 보내기

지갑도 가스도 없는 수신자

퍼블릭 블록체인 위에서 스테이블코인 결제를 만드는 프로젝트였습니다. 체인 위의 송금 자체는 이미 돌아가지만, 그걸 쓰려면 지갑과 가스비를 먼저 이해해야 합니다. 목표는 메신저로 링크를 보내듯 USDT를 보내는 것이었고, 그 안에 벽이 세 개 있었습니다.

첫째, 받는 사람에게 지갑이 없습니다. 지갑을 만들라고 하는 순간 시드 문구 12단어가 등장합니다. 온보딩 첫 화면에 쓴 문구가 그대로 목표였어요. "크립토 모르는 친구도 그냥 링크 열면 USDT, KRW 받아요."

둘째, 지갑이 있어도 가스비가 없습니다. 체인 위에서 토큰을 받으려면 받는 쪽이 네이티브 코인을 들고 있어야 하는데, 처음 받는 사람에게 그게 있을 리가 없죠. 돈을 받기 위해 먼저 돈이 필요한 순환을 어디선가 끊어야 했습니다.

셋째, 앱에 넣어둔 잔액이 그냥 놀고 있습니다. 앱의 잔액은 가만히 둬도 이자가 붙는 페이머니여야 했습니다. 그런데 일반적인 구조에서는 A가 B에게 보내는 순간 자산이 수익 시스템 밖으로 나갔다가 다시 들어와야 해요.

이 글에서는 첫째와 둘째, 그러니까 지갑 없이 받게 만드는 문제와 가스비 없이 받게 만드는 문제만 다룹니다. 셋째 벽을 푼 KaiaPayVault 의 pot 구조와 Aave 이자 회계, 그리고 Cloudflare Workers 위에 올린 API와 타입 생성 파이프라인은 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

얇은 서버, 진실은 체인에

Privy 임베디드 지갑과 ERC-4337 스마트 월렛으로 "지갑"이라는 단어를 화면에서 지웠습니다

사용자는 이메일, Google, X, LINE 중 하나로 로그인만 하면 되고, 시드 문구는 한 번도 보지 않습니다. createOnLogin: "all-users" 로 로그인 즉시 임베디드 지갑을 만들고, 그 위에 스마트 월렛을 얹어 실제 자산은 스마트 월렛 주소가 들고 있게 했어요. 지갑 목록 UI는 walletList: [] 로 아예 비웠습니다. 타깃 사용자 대부분이 크립토 비경험자라, 온보딩 어디에도 지갑 개념을 노출할 수 없었습니다. 대신 스마트 월렛 생성이 로그인 직후 몇 초 걸려서, 홈과 링크 수령 화면 모두 "지갑을 생성중입니다" 스피너를 별도 래퍼 컴포넌트로 두어야 했습니다.

수수료는 Kaia의 fee delegation 트랜잭션 타입과 서버 릴레이로 대납했습니다

paymaster를 붙이는 대신, 체인이 원래 지원하는 FeeDelegatedSmartContractExecution 을 썼습니다. 사용자가 서명한 RLP를 서버가 받아 fee payer 키로 한 번 더 서명하고 klay_sendRawTransaction 으로 쏩니다. 잔고 0원짜리 주소도 자기 트랜잭션을 스스로 서명할 수 있게 되죠. fee payer 개인키가 서버에 있어야 해서, Workers Secrets Store 바인딩으로만 읽도록 묶고 릴레이 엔드포인트에는 인증을 걸었습니다. 브로드캐스트가 간헐적으로 실패해서 3회 재시도 래퍼를 씌웠고요.

송금 링크는 서버에 저장하지 않기로 했습니다

링크를 만들 때 일회용 개인키를 생성해 base58로 압축한 뒤 URL 경로에 실어 보내고, 서버에는 그 키에서 파생된 공개 주소만 남깁니다. 서버 DB가 털려도 남의 돈을 꺼낼 수 없어요. 대신 링크가 무기명 증서가 됩니다. 유출되면 먼저 연 사람이 가져가요. 그래서 컨트랙트 쪽에 24시간 deadline 과 보낸 사람의 회수 권한을 함께 넣었고, UI에도 "다른 사람이 링크를 열지 않도록 주의하세요"를 명시했습니다.

배포와 인프라 구성
배포와 인프라 구성

링크 한 줄과 컨트랙트 하나

전체는 프론트, 엣지 API, 컨트랙트 세 덩어리입니다. 그런데 세 덩어리가 서로를 신뢰하는 정도가 다릅니다. 프론트는 Vite로 빌드한 SPA이고 Cloudflare Pages에 올라갑니다. 로그인과 서명은 Privy가, 잔액 조회는 브라우저가 Kaia RPC를 직접 읽습니다. 홈의 잔액 숫자는 서버를 거치지 않고 getPot(user, token) 을 그대로 읽은 값이에요. 서버가 잔액을 들고 있으면 그 숫자와 체인의 숫자가 언젠가 어긋나기 때문에, 돈에 관한 숫자는 처음부터 체인만 보게 했습니다. 그럼 서버는 무엇을 하냐면, 사람이 읽을 수 있는 문맥을 담당합니다. 컨트랙트는 UUPS 프록시 뒤에 있는 KaiaPayVault 하나입니다. 링크 송금은 이 안에서 임시 pot이라는 형태로 표현됩니다.

핵심 데이터 모델
핵심 데이터 모델

링크 자체가 일회용 지갑이 되게 만들기

이 문제를 처음 만나면 대개 서버에 클레임 토큰을 저장합니다. UUID를 하나 발급해 테이블에 넣고, 받는 사람이 그 URL을 열면 서버가 "이 토큰 유효하네" 확인한 뒤 서비스 운영 지갑에서 대신 송금해 주는 방식이죠. 구현이 제일 빠릅니다. 그런데 이렇게 하면 미수령 자금을 서버가 들고 있는 커스터디 구조가 됩니다. DB 한 줄이 곧 남의 돈이라, 유출되면 그대로 인출이고 실수로 지우면 돈이 증발해요. 규제 측면에서도 성격이 완전히 달라집니다.

링크를 만들 때 generatePrivateKey() 로 일회용 개인키를 뽑고, 0x 를 떼어 base58로 압축한 문자열을 /i/{key} 경로에 실어 프론트로만 내려보냅니다. 서버가 저장하는 건 그 키에서 파생된 publicAddress 뿐이고, 개인키는 응답이 끝나는 순간 사라집니다. 그리고 컨트랙트에서는 그 주소로 바로 돈을 옮기는 게 아니라, owner 를 보낸 사람으로 두고 deadline 을 24시간 뒤로 박은 임시 pot을 만듭니다. 받는 사람이 링크를 열면 브라우저가 base58을 되돌려 키를 복원하고, 그 키로 transferToken 을 직접 서명해 자기 스마트 월렛으로 옮겨 옵니다.

키가 서버에 없으니 서버는 그 돈을 만질 수 없습니다. 동시에 pot의 owner 가 보낸 사람이라, 상대가 받지 않으면 보낸 사람이 회수할 수 있어요. 컨트랙트의 권한 검사가 이 두 경우를 정확히 갈라 줍니다. from == msg.sender 이거나, pots[from][token].owner == msg.sender && owner != from 일 때만 통과시키죠. 앞쪽이 "링크를 받은 사람이 스스로 꺼내는 경우", 뒤쪽이 "보낸 사람이 회수하는 경우"입니다. 게다가 임시 pot 주소로 두 번째 입금이 들어오는 것도 require(pots[to][token].owner == address(0)) 로 막아, 같은 링크가 재사용되지 않습니다. 서버는 여기에 "이미 받았음"을 can_cancel = false 로 기록해 두고, 두 번째로 링크를 연 사람에게는 만료 안내를 띄웁니다.

잔고가 0인 주소가 스스로 서명해서 돈을 꺼내게 하기

임시 주소에 가스비를 뿌려 주거나, 서버가 아예 대신 실행해 주는 방식이 먼저 떠오릅니다.

브라우저에서 링크 키로 FeeDelegatedSmartContractExecution 을 서명해 릴레이로 보내고, 서버가 fee payer 키로 한 번 더 서명해 브로드캐스트합니다.

서명 권한과 지불 책임이 분리되어, 릴레이는 검열은 할 수 있어도 내용은 조작할 수 없습니다. 잔고 0 계정의 가스 추정 실패 같은 덫이 몇 개 더 있었는데, 그 이야기는 따로 적겠습니다.

유저가 할 수 있는 일

앱을 처음 열고 무엇을 하는 서비스인지 여섯 장으로 훑는다
앱을 처음 열고 무엇을 하는 서비스인지 여섯 장으로 훑는다

소셜 계정으로 로그인하면 지갑이 알아서 생긴다
소셜 계정으로 로그인하면 지갑이 알아서 생긴다

계좌번호 대신 쓸 KaiaPay 아이디를 정한다
계좌번호 대신 쓸 KaiaPay 아이디를 정한다

외부 Kaia 지갑에서 페이머니를 채운다
외부 Kaia 지갑에서 페이머니를 채운다

링크를 만들어 아무에게나 보낸다
링크를 만들어 아무에게나 보낸다

받은 링크를 열어 돈을 가져온다
받은 링크를 열어 돈을 가져온다

핸드폰 번호로 보내고 문자로 링크를 넘긴다
핸드폰 번호로 보내고 문자로 링크를 넘긴다

상대의 아이디로 페이머니를 바로 보낸다
상대의 아이디로 페이머니를 바로 보낸다

외부 지갑 주소로 페이머니를 꺼낸다
외부 지갑 주소로 페이머니를 꺼낸다

내 아이디를 복사해 상대에게 알려준다
내 아이디를 복사해 상대에게 알려준다

거래내역을 날짜별로 훑고 체인 explorer까지 확인한다
거래내역을 날짜별로 훑고 체인 explorer까지 확인한다

카드 사전 신청을 넣는다
카드 사전 신청을 넣는다

럭키박스 화면과 받는 방법 안내를 본다
럭키박스 화면과 받는 방법 안내를 본다

데스크톱에서 열면 모바일 앱을 액자 안에서 그대로 쓴다
데스크톱에서 열면 모바일 앱을 액자 안에서 그대로 쓴다

개발용 화면에서 스마트 월렛 호출을 직접 시험한다
개발용 화면에서 스마트 월렛 호출을 직접 시험한다

1 / 1

아직 console.log인 취소 버튼

솔직히 말하면, 데모까지 도달한 흐름과 화면만 남은 기능이 섞여 있습니다.

거래 상세의 "취소하기" 버튼은 아직 console.log("거래 취소") 를 찍습니다. 컨트랙트에는 회수에 필요한 권한 검사와 deadline 이 이미 있는데, 서버의 transactions.deadline 은 여전히 null 이고 코드에는 "TODO: 만료 시간 일괄 설정" 주석이 남아 있어요. 즉 만료 판정을 체인만 알고 서버 목록은 모르는 상태입니다.

구조적으로 아쉬운 건 두 가지입니다. 하나는 컨트랙트 테스트가 한 줄도 없다는 점이에요. Foundry 프로젝트인데 test/ 디렉터리가 비어 있습니다. 이자 인덱스 계산과 임시 pot 권한 검사처럼 틀리면 바로 돈이 새는 로직이야말로 테스트가 먼저였어야 했는데, 메인넷 데모 일정에 밀렸습니다. 다시 만든다면 transferToken 의 세 갈래 권한 케이스부터 fuzz 테스트로 덮고 시작하겠습니다.

다른 하나는 온체인 확정을 클라이언트가 트리거한다는 점입니다. 지금은 프론트가 트랜잭션을 보낸 뒤 confirm-transfer 를 호출해야 서버 상태가 움직입니다. 서버가 영수증을 재검증하니 위조는 막히지만, 사용자가 그 사이에 브라우저를 닫으면 체인에는 성공으로 남고 DB에는 pending 이 남아요. 다시 만든다면 이벤트 인덱서를 하나 두고, 프론트 호출은 화면을 빨리 넘기기 위한 힌트로만 쓰겠습니다.

Read next

강의 PDF로 문제를 만들고 학습 계획을 짜는 앱

LumiQuiz — 2024