인간 인증을 다른 체인·소셜 계정에 잇는 신원 서비스
World ID 증명을 외부 지갑과 소셜 계정까지 잇는 신원 레이어
World Chain에 갇힌 증명
World App 생태계 안에서 도는 미니앱에서 출발한 작업입니다. 문제는 단순합니다. 유저는 오브나 디바이스로 이미 "나는 사람이다"를 증명했는데, 그 증명이 World Chain 안에만 있었어요. 다른 체인의 지갑에도, X 계정에도, 이메일에도 그 증명을 붙일 방법이 없었습니다.
요구는 세 가지였습니다. 첫째, World ID 검증을 외부 지갑과 소셜 계정, 연락처까지 확장해서 온체인에 흔적을 남길 것. 둘째, 그 흔적을 dApp이 isHuman(user, "orb") 한 줄로 조회할 수 있게 만들 것. 셋째, World App 밖의 브라우저에서도 같은 신원으로 로그인되게 할 것. 여기에 봇은 받을 수 없는 토큰 드롭이 붙었습니다.
이 글에서는 지갑과 소셜 계정을 사람 증명에 묶는 축과 그것을 떠받치는 엣지 인프라만 다룹니다. 스펙 하나에서 세 프론트의 클라이언트를 생성한 이야기와 환경변수를 암호화해 레포에 커밋한 배포 파이프라인은 접어 둡니다.

워커 다섯 개를 묶은 스펙 하나
모든 서비스를 Cloudflare Workers 다섯 개로 쪼개고, Postgres 접근은 Hyperdrive 바인딩으로만
유저가 40개 로케일에 흩어져 있어서 리전 하나에 붙는 순간 지구 반대편은 손해를 봅니다. Workers는 엣지에서 돌고 placement: smart 로 DB에 가까운 곳에 배치되니까요. Workers에는 TCP 커넥션 풀이 없습니다. 요청마다 Postgres 커넥션을 새로 열면 트래픽이 조금만 몰려도 DB가 먼저 죽어요. 그래서 env.HYPERDRIVE.connectionString 을 통해서만 붙게 하고, 앱 코드는 커넥션 수명을 아예 신경 쓰지 않도록 했습니다.
SBT 민팅은 서버 EIP-712 서명 + Alchemy 스마트월렛 페이마스터로 가스 없이
유저 지갑에 각 체인 가스를 채우라고 할 수는 없었습니다. 컨트랙트의 mint 는 누구나 부를 수 있고 serverSigner 의 서명만 검증하기 때문에, 트랜잭션을 누가 보내는지는 중요하지 않아요. 릴레이 계정에 온체인 권한을 주면 그 키가 곧 민팅 권한이 됩니다. 그래서 HBTPaymaster.mint 는 호출마다 LocalAccountSigner.generatePrivateKeySigner() 로 일회용 스마트 계정을 만들고, 실제 권한은 서명 키에만 둡니다.
상태 플래그를 앱이 아니라 Postgres 생성 컬럼으로 계산
isValid, isDone, totalAmount 를 앱에서 갱신하면 세 앱이 각자 다른 시점에 갱신합니다. walletConnections.isValid 는 proof와 sign이 둘 다 붙고 deletedAt 이 비어 있을 때만 true인 generatedAlwaysAs 컬럼이라, 어느 경로로 들어와도 판정이 하나입니다. 대신 생성 컬럼은 인덱스와 마이그레이션에서 다루기가 까다로워서, 조건을 바꿀 때마다 컬럼을 다시 만들어야 했습니다.

증명은 폰, 서명은 서버, 순서는 DB
전체는 다섯 개의 Worker와 Postgres 하나로 끝납니다. hp-miniapp 은 World App 안에서 도는 React SPA고, hp-web 은 브라우저용 콘솔, hp-link 은 공유 링크를 SSR로 그리는 React Router 앱, hp-api 는 Hono로 쓴 단일 API, hp-metadata 는 SBT의 tokenURI 를 DB에서 만들어 주는 작은 워커입니다. 프론트 셋은 전부 hp-api 의 OpenAPI 스펙에서 생성된 클라이언트만 씁니다.

World App을 한 번도 열지 않은 지갑에 사람 증명을 붙이기
가장 흔한 접근은 미니앱 안에서 외부 지갑을 연결하려는 것입니다. 그런데 World App 웹뷰에는 메타마스크가 없죠. 그러면 다음 수순은 웹에서 지갑 서명만 받아 서버로 보내고 "이 유저의 지갑"으로 저장하는 것입니다. 이러면 서명은 지갑 소유만 증명할 뿐, 그 지갑을 사람이 쥐고 있다는 것도, 그 사람이 이 계정의 주인이라는 것도 증명하지 못합니다. World ID 증명을 따로 받아 온다 해도 그 증명이 이 연결 건에 대한 것인지 확인할 방법이 없고요.
두 증거를 하나의 행에 묶었습니다. 브라우저에서는 wagmi로 OwnershipProof EIP-712 서명을 받아 POST /wallet-connections 를 부릅니다. 서버는 서명의 타임스탬프가 3분 안인지 확인하고 recoverTypedDataAddress 로 주소를 복구한 뒤, 만료 3분짜리 wallet_connections 행과 알림 한 건을 같은 트랜잭션에서 만듭니다. 알림의 href 는 /verify-wallet-connection/{connectionId} 입니다. 유저가 폰에서 미니앱 알림함을 열고 그 카드를 누르면, 미니앱은 MiniKit.commandsAsync.verify({ action: "verify-ownership", signal: connectionId }) 를 호출합니다. 서버는 signal !== connectionId 면 즉시 거절하고, hashToField(connectionId) 로 만든 signal_hash 로 World 검증 API를 통과시킨 뒤에야 proof를 저장하고 hbt_ids 번호를 발급해 SBT를 민팅합니다. 그동안 브라우저는 2초 간격으로 같은 connection을 조회하다가 status === "COMPLETED" 를 보면 화면을 넘깁니다.
World ID 증명의 signal에 connection id가 들어가 있으므로, 그 증명은 이 연결 한 건에만 유효합니다. 다른 연결에 복사해 붙일 수 없고, 같은 연결에 두 번 붙일 수도 없어요(proofConnectedAt 검사). 게다가 isValid 는 proofId 와 signId 가 둘 다 있고 deletedAt 이 비어 있을 때만 true가 되는 생성 컬럼이라, 두 증거 중 하나만 있는 행은 어떤 조회에도 잡히지 않습니다. 사용자 입장에서는 브라우저에서 서명하고 폰에서 확인 한 번 누르면 끝이고, QR도 딥링크도 필요 없습니다.
선착순 드롭에서 같은 사람에게 두 번 나가지 않게 만들기
선착순이니까 컨트랙트에 카운터를 두고 먼저 도착한 트랜잭션이 이기게 하는 게 자연스러워 보입니다.
순서 판정은 DB 한 곳, 재실행 방지는 체인 한 곳으로 갈랐습니다.
행 잠금 덕분에 등수는 동시에 눌러도 하나씩만 매겨집니다. 진 사람은 트랜잭션을 아예 만들지 않고 OUT_OF_RANK 와 자기 등수만 받으니 가스도 실패 화면도 없습니다.
유저가 할 수 있는 일
저장하지 않은 nonce와 실험 코드
먼저 인정할 것부터. 미니앱 로그인의 nonce를 저장하지 않고 computeHMAC(nonce, JWT_SECRET) 로만 검증합니다. 서버가 상태를 안 가져도 되니 Workers와 잘 맞지만, HMAC 안에 만료도 1회성도 들어 있지 않아서 같은 nonce와 hmac 쌍을 다시 쓸 여지가 남습니다. SIWE 메시지 자체에 만료가 있어 실제 위험은 크지 않았지만, 다시 만든다면 nonce에 발급 시각을 넣어 함께 서명하고 KV로 1회성을 보장하겠습니다.
두 번째는 변명의 여지가 없는 쪽입니다. 초기 실험으로 만든 pay-link 코드에 펀딩 계정의 개인키와 Alchemy 키가 소스에 그대로 박힌 채 남았습니다(packages/012_api/src/services/pay-link.ts, endpoints/links/confirm-deposit.ts). 링크 금고 컨트랙트로 넘어가면서 쓰지 않게 된 경로였지만, 라우터에서 지우고 키를 폐기했어야 합니다. "임시 코드"라는 이름표는 지워야 할 이유이지 남겨둘 이유가 아니었어요.
마지막으로 테스트가 한 줄도 없습니다. forge-std를 받아 두고도 컨트랙트 테스트를 쓰지 못했고, API에도 계약 테스트가 없습니다. 3주에 워커 5개와 컨트랙트 4개를 올리는 일정에서 가장 먼저 잘려 나간 게 그것이었는데, 서버 서명으로 토큰이 움직이는 시스템에서는 잘못된 선택이었다고 생각합니다.













