@nexus-cross/pop 1.3.10-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,268 @@
1
+ ---
2
+ name: nexus-cross-pop
3
+ description: Integrate @nexus-cross/pop (SafeDrop / One Pop) — send ERC20 tokens to an X (Twitter) handle and let the recipient claim them with a link + X login. Use when the user asks about @nexus-cross/pop, SafeDrop, One Pop, claim links, drop inbox, or X-handle token transfers.
4
+ ---
5
+
6
+ # @nexus-cross/pop — Agent Integration Guide
7
+
8
+ This file ships inside the published package (`node_modules/@nexus-cross/pop/AGENTS.md`).
9
+ It is the machine-readable contract for wiring this SDK. Follow it literally; the
10
+ rules in **Hard rules** are correctness/security requirements, not style.
11
+
12
+ Human docs: `README.md` (same directory). Backend contract: One Pop API (Swagger 2.0),
13
+ mirrored at `src/infrastructure/openapi.json` in the source repo.
14
+
15
+ ## 1. What this package does
16
+
17
+ Sponsor deposits ERC20 into a SafeDrop escrow addressed to a **social handle** (X).
18
+ A per-drop **temporary claim wallet** holds custody; the recipient proves handle
19
+ ownership via X OAuth, gets that wallet's private key from the backend, and withdraws
20
+ with gas sponsored by the contract. The plaintext `secret` lives **only** in the claim
21
+ link's `#fragment` — the backend never sees it, so backend + link are each insufficient alone.
22
+
23
+ ## 2. Entry points — pick one, don't mix
24
+
25
+ | Import | Contents | Requires |
26
+ |---|---|---|
27
+ | `@nexus-cross/pop` | domain types, use cases, pure utils, `createSafeDrop` | nothing |
28
+ | `@nexus-cross/pop/adapters` | `createSafeDropClient`, viem/HTTP adapters, X OAuth, cross-auth | `viem`, `fetch` |
29
+ | `@nexus-cross/pop/react` | `SafeDropProvider`, `useSafeDrop` | `react` |
30
+
31
+ Default for an app: `createSafeDropClient` (adapters) + `SafeDropProvider` (react).
32
+ `viem` and `react` are **optional peers** — install only what the chosen entry point needs.
33
+
34
+ ```sh
35
+ pnpm add @nexus-cross/pop viem # + react if using ./react
36
+ ```
37
+
38
+ ## 3. Required configuration
39
+
40
+ | What | Where it goes | Notes |
41
+ |---|---|---|
42
+ | SafeDrop contract address | `createSafeDropClient({ contractAddress })` | per-chain deployment |
43
+ | viem `publicClient` / `walletClient` / `chain` | same | walletClient = connected sponsor wallet |
44
+ | claim page base URL | `createSafeDropClient({ claimBaseUrl })` | the route that handles the claim link |
45
+ | SIWE JWT provider | `createSafeDropClient({ api: { getJwt } })` | **required** for deposit and all authed reads |
46
+ | X OAuth2 client id + redirect URI | `new XAuthClient({ clientId, redirectUri })` | redirect URI must equal the claim route and be registered on the X app |
47
+ | X OAuth2 client secret | **server route only** | never ship to the client bundle |
48
+
49
+ Environment variables the package reads itself (`adapters/endpoints.ts`) — pick the
50
+ prefix matching the framework, Vite `VITE_` or Next.js `NEXT_PUBLIC_`:
51
+
52
+ | Variable | Purpose |
53
+ |---|---|
54
+ | `VITE_ONE_POP_ENVIRONMENT` / `NEXT_PUBLIC_ONE_POP_ENVIRONMENT` / `ONE_POP_ENVIRONMENT` | `dev` \| `stage` \| `production` (default `production`) → picks the default API base URL |
55
+ | `VITE_ONE_POP_API_BASE_URL` / `NEXT_PUBLIC_ONE_POP_API_BASE_URL` | override the One Pop API base URL |
56
+ | `VITE_CROSS_AUTH_URL` / `NEXT_PUBLIC_CROSS_AUTH_URL` | override the cross-auth (SIWE) base URL |
57
+
58
+ Base URLs must be `https` (or `http://localhost` for local dev) or construction throws.
59
+ Everything else (contract address, claim base URL, X client id) is passed as options —
60
+ the package does not read it from the environment.
61
+
62
+ ## 4. Wiring
63
+
64
+ ```ts
65
+ import { createSafeDropClient, CrossAuthClient } from '@nexus-cross/pop/adapters';
66
+ import { createPublicClient, createWalletClient, custom, http } from 'viem';
67
+
68
+ const publicClient = createPublicClient({ chain, transport: http() });
69
+ const walletClient = createWalletClient({ chain, transport: custom(window.ethereum) });
70
+
71
+ // SIWE login → JWT (needed by deposit + every authed read)
72
+ const auth = new CrossAuthClient();
73
+ const { message } = await auth.unsignedHash(chain.id, address);
74
+ const signature = await walletClient.signMessage({ account: address, message });
75
+ const { token } = await auth.siweToken(address, signature);
76
+
77
+ const safeDrop = createSafeDropClient({
78
+ contractAddress: SAFEDROP_ADDRESS,
79
+ publicClient,
80
+ walletClient,
81
+ chain,
82
+ claimBaseUrl: 'https://app.example/claim',
83
+ permit: false, // true only if BOTH the deployment and the token support EIP-2612
84
+ api: { getJwt: () => token },
85
+ });
86
+ ```
87
+
88
+ React: wrap once with `<SafeDropProvider client={safeDrop}>`, read with `useSafeDrop()`.
89
+ The provider only carries the client through context — no connection logic lives there.
90
+
91
+ ## 5. Flows
92
+
93
+ ### Send (sponsor)
94
+
95
+ ```ts
96
+ const envelopes = await safeDrop.listEnvelopes(); // optional card designs
97
+ const { withdrawLink, claimAddress } = await safeDrop.deposit({
98
+ sender: address,
99
+ recipient: { provider: 'x', handle: 'theo_13303' },
100
+ token: ERC20_ADDRESS,
101
+ amount: 1_000_000_000_000_000_000n, // BigInt, raw wei-scale
102
+ message: 'Happy birthday!', // optional, <= 140 runes
103
+ envelopeId: envelopes[0]?.id, // optional
104
+ });
105
+ ```
106
+
107
+ Then hand the link to the recipient (X DM compose):
108
+ `location.assign(buildComposeUrl(buildDmText(withdrawLink), recipientId))`.
109
+
110
+ ### Claim (recipient, on the claim route)
111
+
112
+ ```ts
113
+ const { sender, secret } = parseWithdrawLink(location.href);
114
+ const { oauth } = await xauth.complete(location.href); // X OAuth2 PKCE callback
115
+ const { claimKey, claimAddress } = await safeDrop.retrieveClaimKey({ senderAddress: sender!, oauth });
116
+ await safeDrop.withdraw({ recipient: myAddress, claimAddress, secret: secret!, claimKey });
117
+ ```
118
+
119
+ `retrieveClaimKey` is the point of no return — after it resolves, key custody is the client's.
120
+ It returns **only the latest pending drop** for that `(sender, handle)` pair and takes no
121
+ `claimAddress` selector; multiple drops from the same sender are claimed one link at a time.
122
+
123
+ ### Inbox / social surfaces
124
+
125
+ ```ts
126
+ await safeDrop.connectX({ oauth }); // required once before listDrops
127
+ const conn = await safeDrop.getXConnection(); // null when not connected
128
+ const inbox = await safeDrop.listDrops(); // { items, count, totalAmount, hasClaimedBefore }
129
+ const top = await safeDrop.getLeaderboard({ token: ERC20_ADDRESS });
130
+ const page = await safeDrop.listHistories({ event: 'Withdrawn', page: 1, pageSize: 20 });
131
+ await safeDrop.disconnectX();
132
+ ```
133
+
134
+ `inbox.hasClaimedBefore === false` ⇒ the first claim must be an individual claim; gate any
135
+ batch-claim CTA on it.
136
+
137
+ ### Refund
138
+
139
+ ```ts
140
+ await safeDrop.refund({ claimAddress }); // sponsor only, after expiry
141
+ ```
142
+
143
+ ### Batch claim — drain every drop for a handle in one flow
144
+
145
+ ```ts
146
+ const { txHashes, recipient } = await safeDrop.batchClaim({
147
+ id: myHandle, // normalized internally
148
+ oauth, // X OAuth proof (handle ownership)
149
+ maxCount: 30, // optional page size, default 30
150
+ });
151
+ ```
152
+
153
+ `batchClaim` does the whole sequence in the order the contract enforces:
154
+ read `validatorNonceById(keccak256(utf8(id)))` → request the backend signature →
155
+ read `validatorClaimDigest(recipient, id, nonce, deadline)` on-chain → **verify the signature
156
+ recovers to `validator()` before spending gas** → call `withdrawByValidator` repeatedly until
157
+ `liveDropCountById(id)` is 0, reusing the one signature.
158
+
159
+ Rules specific to this path:
160
+
161
+ - **The recipient is the SIWE-authenticated address**, not a parameter you choose. Passing a
162
+ different `recipient` fails with `SIGN_FAILED`. To claim to another wallet, do SIWE with it.
163
+ - **The transaction must be sent by the recipient wallet** — the contract compares `msg.sender`
164
+ against the signed recipient. Unlike individual `withdraw`, the temporary claim wallet cannot
165
+ send this, and there is no gas sponsorship: the recipient needs gas.
166
+ - **No `secret` is involved.** The validator signature is the gate for this path.
167
+ - `maxCount` is a page size and is **not** covered by the signature — that is why one signature
168
+ survives every page. Do not re-request a signature per page.
169
+ - `hasClaimedBefore === false` (from `listDrops`) means the first claim must be an individual
170
+ claim; do not offer batch claim yet. The SDK also enforces this on-chain: batch claim aborts
171
+ with `DROP_NOT_FOUND` when `claimedRecipientOf(id)` is zero.
172
+ - The SDK cross-checks the signed recipient against `claimedRecipientOf(id)` and refuses to send
173
+ when they differ (`SIGN_FAILED`). This is a client-side guard — whether the contract enforces
174
+ it is **not verified** (the source is unverified and the signature check runs first), so do not
175
+ weaken or bypass it.
176
+ - On `SIGN_FAILED` the backend signed something the contract will reject — a different EIP-712
177
+ domain/type, a stale nonce, or a `personal_sign` prefix. Do not retry; report it.
178
+
179
+ The on-chain contract (verified against deployed bytecode, CROSS testnet 612044):
180
+
181
+ ```
182
+ domain { name: 'ONEpop', version: '1', chainId, verifyingContract: <SafeDrop> }
183
+ type ValidatorClaim(address recipient,string id,uint256 nonce,uint256 deadline)
184
+ nonce validatorNonceById(bytes32 keccak256(utf8(id))) // hashed key, NOT the raw string
185
+ submit withdrawByValidator(address recipient, string id, uint256 nonce,
186
+ uint256 deadline, uint256 maxCount, bytes signature)
187
+ errors ValidatorSigExpired() → deadline passed | InvalidValidatorNonce() → stale nonce
188
+ ECDSAInvalidSignature() → signer is not validator()
189
+ ```
190
+
191
+ These functions are absent from the bundled `safedrop_abi.json` (a Foundry artifact that does not
192
+ match the deployment); the SDK carries its own bytecode-verified ABI in `adapters/validatorAbi.ts`.
193
+
194
+ ### Live notifications
195
+
196
+ `GET /events/subscribe` is a plain SSE endpoint (`event: withdrawn` frames, `: ping` keepalives).
197
+ The SDK does not wrap it — use `EventSource` directly. Delivery is best-effort; reconcile with
198
+ `listHistories()`.
199
+
200
+ ## 6. Hard rules
201
+
202
+ 1. **BigInt end-to-end for wei-scale values.** `amount`, `balance`, `totalAmount`,
203
+ `lastDepositAmount`, gas, fees. Never `Number()`/`parseInt()`/`parseFloat()` them, never feed a
204
+ formatted display string back into arithmetic, and floor (never round up) when formatting.
205
+ The API sends wei as decimal strings; the adapters already convert them to BigInt.
206
+ 2. **Never send `secret` anywhere.** It belongs in the link `#fragment` only — no query string, no
207
+ logs, no analytics, no backend call. Sending it collapses the trust split.
208
+ 3. **`getJwt` is mandatory** for `deposit`, `listDrops`, `listHistories`, and all `x-connections`
209
+ calls. Without it those reject with `UNAUTHORIZED` before any request is sent.
210
+ 4. **`permit: true` only when the deployment and the token both support EIP-2612.** Enabling it
211
+ against a non-permit deployment reverts (missing selector).
212
+ 5. **Do not change `withdrawFees` away from type-2 dynamic fees with tip ≥ 1 gwei** — gas
213
+ abstraction (sponsor pays) depends on it, and the claim wallet has a zero balance.
214
+ 6. **`message` ≤ 140 runes** (code points) and `envelopeId` must come from `listEnvelopes()`;
215
+ otherwise the backend rejects with `INVALID_PARAM` / `ENVELOPE_NOT_FOUND`.
216
+ 7. **Handles are normalized** by the SDK (`normalizeHandle`); pass the same handle to deposit and
217
+ to the API — do not hand-roll a second normalization.
218
+ 8. **`DropState.token` and `DropState.expiry` are optional.** Deployments differ in what `drops`
219
+ returns (the permit deployment returns five fields, no expiry). `expiry === undefined` means
220
+ *unknown*, not *not expired* — never drive refund/expiry UI off a missing value.
221
+
222
+ ## 7. API surface
223
+
224
+ | `safeDrop` method | Endpoint / chain call | Auth |
225
+ |---|---|---|
226
+ | `deposit` | `POST /wallets` + `approve`/`deposit` (or permit deposit) | JWT |
227
+ | `retrieveClaimKey` | `POST /wallets/private-key` | X OAuth token (body) |
228
+ | `withdraw` | `withdraw(...)` from the claim wallet | claim key |
229
+ | `refund` | `refund(claimAddr)` | sponsor wallet |
230
+ | `getDrop` | `drops(claimAddr)` | — |
231
+ | `listDrops` | `GET /drops` | JWT + active X connection |
232
+ | `listEnvelopes` | `GET /envelopes` | public |
233
+ | `getLeaderboard` | `GET /leaderboards?token=` | public |
234
+ | `listHistories` | `GET /histories` | JWT |
235
+ | `getXConnection` / `connectX` / `disconnectX` | `GET`/`POST`/`DELETE /x-connections` | JWT |
236
+ | `batchClaim` | `POST /batch-claim-signature` + `validatorNonceById`/`validatorClaimDigest`/`validator`/`withdrawByValidator` | JWT + X OAuth token + recipient wallet |
237
+ | `requestBatchClaimSignature` | `POST /batch-claim-signature` (low-level; prefer `batchClaim`) | JWT + X OAuth token |
238
+
239
+ ## 8. Errors
240
+
241
+ Everything rejects with `SafeDropError` — branch on `err.code`, never on the message.
242
+
243
+ | `code` | Meaning / action |
244
+ |---|---|
245
+ | `UNAUTHORIZED` | no/expired SIWE JWT → re-run the SIWE login |
246
+ | `INVALID_OAUTH_TOKEN` | X token missing `tweet.read`+`users.read`, or app-only → re-auth the user |
247
+ | `X_CONNECTION_NOT_FOUND` | call `connectX()` first (also returned by `listDrops`) |
248
+ | `WALLET_NOT_FOUND` | no pending claim wallet for that (sender, handle) — already claimed or refunded |
249
+ | `PENDING_LIMIT_EXCEEDED` | too many pending drops for this pair → ask the user to wait/claim |
250
+ | `SEND_BLOCKED` | sender blocked by the backend → surface, do not retry |
251
+ | `ENVELOPE_NOT_FOUND` | stale `envelopeId` → refresh `listEnvelopes()` |
252
+ | `INVALID_PARAM` | bad request body/query (e.g. message > 140 runes) |
253
+ | `RATE_LIMITED` | back off and retry later |
254
+ | `INVALID_AMOUNT` / `INVALID_TOKEN` / `MISSING_*` | client-side validation before any network/chain call |
255
+ | `SIGN_FAILED` | validator signature does not match the contract (see Batch claim) — report, don't retry |
256
+ | `CHAIN_ERROR` | tx reverted or RPC failed; `details.revertName` carries the decoded custom error |
257
+ | `API_ERROR` | unmapped backend failure (5xx, malformed body) or an unimplemented optional port method |
258
+
259
+ ## 9. Common mistakes
260
+
261
+ - Converting `amount` to `Number` for display math → silent precision loss above ~0.009 ETH in wei.
262
+ - Calling `listDrops()` before `connectX()` → `X_CONNECTION_NOT_FOUND`.
263
+ - Passing the claim `secret` through a query parameter or logging it.
264
+ - Building the claim link by hand instead of using the returned `withdrawLink` /
265
+ `buildWithdrawLink` (the `#fragment` placement is load-bearing).
266
+ - Injecting a partial `SafeDropApiPort` mock and calling the query methods → `API_ERROR`
267
+ ("not implemented"); implement the methods your test path touches.
268
+ - Enabling `permit: true` on a deployment whose `deposit` has no permit signature.
package/README.md ADDED
@@ -0,0 +1,343 @@
1
+ # @nexus-cross/pop
2
+
3
+ SafeDrop client SDK — 소셜 핸들(X)로 ERC20 토큰을 예치하고, 수령인이 링크 + X 로그인으로 수령하는 흐름을 다룬다. 프레임워크 무관 core + viem/HTTP 어댑터 + 얇은 React Provider.
4
+
5
+ ```
6
+ npm i @nexus-cross/pop viem # react는 선택
7
+ ```
8
+
9
+ `viem`·`react`는 **optional peer** — `.`(core)만 쓰면 둘 다 불필요. `./adapters`는 `viem`, `./react`는 `react`가 필요하다.
10
+
11
+ > **AI 에이전트로 통합한다면** `node_modules/@nexus-cross/pop/AGENTS.md`를 읽히면 된다 —
12
+ > 설치·환경변수·와이어링·필수 규칙·에러 분기가 에이전트용으로 정리돼 함께 배포된다.
13
+ > 백엔드 계약 원본은 `@nexus-cross/pop/openapi.json`.
14
+
15
+ ## Entry points
16
+
17
+ | import | 내용 | 의존 |
18
+ |---|---|---|
19
+ | `@nexus-cross/pop` | 도메인 타입, usecase, 순수 유틸, `createSafeDrop` facade | 없음 |
20
+ | `@nexus-cross/pop/adapters` | viem/HTTP 어댑터, `createSafeDropClient`, X OAuth, cross-auth | viem, fetch |
21
+ | `@nexus-cross/pop/react` | `SafeDropProvider`, `useSafeDrop` | react |
22
+
23
+ 대부분의 앱은 **`createSafeDropClient`(adapters) + `SafeDropProvider`(react)** 만 쓰면 된다.
24
+
25
+ ## 개념
26
+
27
+ - **claimAddress** — 드롭당 임시 지갑 주소. 온체인 조회 키(`drops(claimAddress)`).
28
+ - **secret** — 평문 비밀 문구. **어떤 서버에도 전송되지 않고** 수령 링크의 `#fragment`에만 담긴다.
29
+ - **withdrawLink** — `…/claim?sender=<addr>#<secret>`. 이 링크를 X DM으로 수령인에게 보낸다.
30
+ - **claimKey** — 임시 claim 지갑의 개인키. 수령인이 X OAuth 검증 후 백엔드에서 받아 가스 대납 withdraw에 쓴다.
31
+
32
+ 신뢰 분리: 백엔드는 claimKey를 갖지만 secret이 없어 자금을 옮길 수 없고, 링크만으로도 OAuth 없이 키를 못 받는다.
33
+
34
+ ## 클라이언트 만들기
35
+
36
+ ```ts
37
+ import { createSafeDropClient } from '@nexus-cross/pop/adapters';
38
+ import { createPublicClient, createWalletClient, custom, http } from 'viem';
39
+
40
+ const publicClient = createPublicClient({ chain, transport: http() });
41
+ const walletClient = createWalletClient({ chain, transport: custom(window.ethereum) }); // 연결된 지갑
42
+
43
+ const safeDrop = createSafeDropClient({
44
+ contractAddress: '0x…SafeDrop',
45
+ publicClient,
46
+ walletClient,
47
+ chain,
48
+ claimBaseUrl: 'https://one-pop.example/claim',
49
+ // api.baseUrl 미지정 시 VITE_/NEXT_PUBLIC_ONE_POP_* 환경변수로 결정
50
+ api: { getJwt: async () => siweJwt }, // deposit(createClaimWallet)에 SIWE JWT 필요
51
+ });
52
+ ```
53
+
54
+ `getJwt`는 cross-auth SIWE 로그인으로 얻는다:
55
+
56
+ ```ts
57
+ import { CrossAuthClient } from '@nexus-cross/pop/adapters';
58
+
59
+ const auth = new CrossAuthClient(); // baseUrl 미지정 시 환경변수/기본값
60
+ const { message } = await auth.unsignedHash(chainId, address);
61
+ const signature = await walletClient.signMessage({ account: address, message });
62
+ const { token } = await auth.siweToken(address, signature); // token = getJwt가 반환할 값
63
+ ```
64
+
65
+ ## 1) Deposit — 토큰 보내기
66
+
67
+ ```ts
68
+ const { withdrawLink, claimAddress } = await safeDrop.deposit({
69
+ sender: address, // 연결된 지갑 = 스폰서
70
+ recipient: { provider: 'x', handle: 'theo_13303' },
71
+ token: '0x…ERC20',
72
+ amount: 1_000000n, // raw wei-scale, BigInt 필수
73
+ message: '생일 축하해!', // 선택 — 수령인에게 표시(≤140 runes)
74
+ envelopeId: 'classic-coral', // 선택 — listEnvelopes()의 id
75
+ // secret 미지정 시 자동 생성
76
+ });
77
+ ```
78
+
79
+ `message`·`envelopeId`는 **백엔드에만 저장**되는 메타데이터다(컨트랙트에 필드 없음).
80
+
81
+ 내부 흐름: 임시 claim 지갑 생성(백엔드) → `approve` → `deposit` → 링크 생성. `amount`는 **BigInt로 끝까지** 유지한다 (Number 변환 금지).
82
+
83
+ 수령인에게 링크 전달 (X DM 작성창):
84
+
85
+ ```ts
86
+ import { buildDmText, buildComposeUrl } from '@nexus-cross/pop';
87
+ location.assign(buildComposeUrl(buildDmText(withdrawLink), recipientId));
88
+ ```
89
+
90
+ ## 2) Withdraw — 수령인이 받기
91
+
92
+ 수령 페이지(`claimBaseUrl`)에서:
93
+
94
+ ```ts
95
+ import { parseWithdrawLink } from '@nexus-cross/pop';
96
+ import { XAuthClient, HttpXAuthAdapter } from '@nexus-cross/pop/adapters';
97
+
98
+ const { sender, secret } = parseWithdrawLink(location.href);
99
+
100
+ // (a) X OAuth2 PKCE — 리다이렉트는 앱이 담당
101
+ const xauth = new XAuthClient({
102
+ clientId: X_CLIENT_ID,
103
+ redirectUri: 'https://one-pop.example/claim',
104
+ port: new HttpXAuthAdapter({ /* 토큰 교환 프록시 */ }),
105
+ });
106
+ // 시작: const { authorizeUrl } = await xauth.start(); location.assign(authorizeUrl);
107
+ // 콜백에서:
108
+ const { oauth } = await xauth.complete(location.href);
109
+
110
+ // (b) OAuth 검증 → 임시 claim 키/주소 수령 (point of return)
111
+ const { claimKey, claimAddress } = await safeDrop.retrieveClaimKey({
112
+ senderAddress: sender!,
113
+ oauth,
114
+ });
115
+
116
+ // (c) 가스 대납 withdraw — 임시 지갑이 직접 tx 전송
117
+ const { txHash } = await safeDrop.withdraw({
118
+ recipient: myReceivingAddress, // 수령인이 지정, 서명에 바인딩
119
+ claimAddress,
120
+ secret: secret!,
121
+ claimKey,
122
+ });
123
+ ```
124
+
125
+ ## 3) Refund — 만료 후 환불
126
+
127
+ ```ts
128
+ await safeDrop.refund({ claimAddress }); // 만료 후 sponsor(msg.sender)만 가능
129
+ ```
130
+
131
+ ## 조회 — 드롭 상태 (온체인)
132
+
133
+ ```ts
134
+ const drop = await safeDrop.getDrop(claimAddress); // 없으면 null
135
+ // drop: DropState { claimAddress, sponsor, amount, secretHash, salt, id, token?, expiry? }
136
+ ```
137
+
138
+ ⚠️ `token`·`expiry`는 **optional**이다 — 배포본마다 `drops`의 반환 필드가 다르다(permit
139
+ 배포본은 5개: sponsor/amount/secretHash/salt/id). 어댑터가 두 형태를 모두 디코딩하고
140
+ `token`은 에스크로 `token()`으로 채우지만, **`expiry`는 5필드 배포본에서 `undefined`** 다.
141
+ 만료 판정 전에 존재 여부를 확인하라(`undefined` = 만료 아님이 아니라 *알 수 없음*).
142
+
143
+ ## 4) 백엔드 조회 — 인박스 · X 연결 · 리더보드 · 히스토리
144
+
145
+ `amount`/`balance` 등 wei-scale 값은 **전부 BigInt로 정규화되어** 나온다(API는 10진 문자열).
146
+
147
+ ```ts
148
+ // X 계정 ↔ 지갑 연결 — listDrops의 선행 조건
149
+ await safeDrop.connectX({ oauth }); // XAuthClient.complete()로 얻은 oauth
150
+ const conn = await safeDrop.getXConnection(); // 연결 없으면 null (에러 아님)
151
+ await safeDrop.disconnectX(); // idempotent
152
+
153
+ // 내게 온 수령 대기 목록 (Bearer JWT + 활성 X 연결)
154
+ const inbox = await safeDrop.listDrops({ token: '0x…ERC20' });
155
+ // { items: PendingDrop[], count, totalAmount: bigint, hasClaimedBefore }
156
+ // PendingDrop { claimAddress, token, amount: bigint, message, envelopeId, depositedAt, sender }
157
+
158
+ // 카드 디자인 · 리더보드 (공개 — JWT 불필요)
159
+ const envelopes = await safeDrop.listEnvelopes({ locale: 'ko' });
160
+ const top = await safeDrop.getLeaderboard({ token: '0x…ERC20' });
161
+
162
+ // 내 온체인 이벤트 (Deposited | Withdrawn | Refunded | BatchWithdrawn)
163
+ const page = await safeDrop.listHistories({ event: 'Withdrawn', page: 1, pageSize: 20 });
164
+ ```
165
+
166
+ `inbox.hasClaimedBefore === false`면 **첫 수령은 개별 claim만** 가능하다 — 일괄 수령 CTA를 이 값으로 게이트한다.
167
+
168
+ ## 5) 일괄 수령 (batch claim) — 한 핸들에 쌓인 드롭 전량 회수
169
+
170
+ ```ts
171
+ const { txHashes, recipient } = await safeDrop.batchClaim({
172
+ id: myHandle, // 내부에서 normalizeHandle
173
+ oauth, // 핸들 소유 증명 (X OAuth)
174
+ maxCount: 30, // 선택 — tx 1건이 처리할 건수(기본 30)
175
+ });
176
+ ```
177
+
178
+ 컨트랙트가 강제하는 순서대로 진행한다:
179
+ `validatorNonceById(keccak256(utf8(id)))` → 백엔드 서명 발급 → 온체인
180
+ `validatorClaimDigest(recipient, id, nonce, deadline)` 조회 → **서명이 `validator()`로
181
+ 복원되는지 가스 쓰기 전에 검증** → `liveDropCountById(id)`가 0이 될 때까지
182
+ `withdrawByValidator` 반복(서명 1건 재사용).
183
+
184
+ | 개별 수령과 다른 점 | 내용 |
185
+ |---|---|
186
+ | secret | **불필요** — 게이트는 validator 서명이다 |
187
+ | 수령 주소 | **SIWE 인증 주소로 고정**. 다른 주소를 넘기면 `SIGN_FAILED`(그 주소로 SIWE 재로그인해야 함) |
188
+ | tx 전송자 | **수령인 본인 지갑**(컨트랙트가 `msg.sender` 대조). 임시 claim 지갑 불가 = **가스 대납 없음** |
189
+ | 서명 재사용 | `maxCount`는 서명 대상이 아니라 페이지 크기 → 페이지마다 재발급 금지 |
190
+ | 첫 수령 | 개별 claim이 선행돼야 한다 — 온체인 `claimedRecipientOf(id)`가 zero면 `DROP_NOT_FOUND`로 차단 |
191
+ | 목적지 교차검증 | 서명된 recipient ≠ `claimedRecipientOf(id)`면 서명이 유효해도 전송하지 않음(`SIGN_FAILED`) |
192
+
193
+ `SIGN_FAILED`가 나면 백엔드 서명이 컨트랙트가 기대하는 값과 다르다(EIP-712 domain/type 불일치,
194
+ 낡은 nonce, `personal_sign` 프리픽스). 재시도해도 동일하게 실패하니 그대로 노출한다.
195
+
196
+ <details>
197
+ <summary>온체인 계약 (배포 바이트코드 검증 — CROSS testnet 612044)</summary>
198
+
199
+ ```
200
+ domain { name: 'ONEpop', version: '1', chainId, verifyingContract: <SafeDrop> }
201
+ type ValidatorClaim(address recipient,string id,uint256 nonce,uint256 deadline)
202
+ nonce validatorNonceById(bytes32) ← keccak256(utf8(id)). **원문 string 아님**
203
+ submit withdrawByValidator(address recipient, string id, uint256 nonce,
204
+ uint256 deadline, uint256 maxCount, bytes signature)
205
+ 고정 claimedRecipientOf(string id) → 개별 수령으로 확정된 주소(미수령이면 zero)
206
+ 검증 순서 deadline(ValidatorSigExpired) → nonce(InvalidValidatorNonce) → 서명(ECDSAInvalidSignature)
207
+ ```
208
+
209
+ **미검증 2건**: 컨트랙트가 `msg.sender == recipient`와 `recipient == claimedRecipientOf(id)`를
210
+ 강제하는지는 확인하지 못했다(소스 미공개 + 서명 검증이 먼저라 블랙박스 구분 불가). pop이
211
+ 두 조건을 클라이언트에서 선제 차단하지만 온체인 강제의 대체물은 아니다 —
212
+ `docs/pop/02-frontend-dependencies.md#검증-한계-중요` 참조.
213
+
214
+ 이 함수들은 `safedrop_abi.json`(Foundry 산출물, 배포본과 불일치)에 없다 — SDK는
215
+ `adapters/validatorAbi.ts`에 바이트코드로 검증한 ABI를 따로 들고 있다.
216
+ 서명만 직접 받아 쓰려면 저수준 `safeDrop.requestBatchClaimSignature({ id, oauth, nonce, deadline })`.
217
+ </details>
218
+
219
+ ## 실시간 알림 (SSE)
220
+
221
+ `GET /events/subscribe`는 pop이 감싸지 않는다 — `EventSource`로 직접 구독하고(경로는
222
+ `DEFAULT_POP_API_PATHS.eventsSubscribe`), best-effort라 유실분은 `listHistories()`로 보정한다.
223
+
224
+ ## React
225
+
226
+ ```tsx
227
+ import { SafeDropProvider, useSafeDrop } from '@nexus-cross/pop/react';
228
+
229
+ <SafeDropProvider client={safeDrop}>
230
+ <App />
231
+ </SafeDropProvider>;
232
+
233
+ // 어디서든
234
+ const safeDrop = useSafeDrop();
235
+ ```
236
+
237
+ Provider는 client를 context로 넣기만 한다 — 연결 로직은 없다.
238
+
239
+ ## 환경변수
240
+
241
+ | 변수 | 용도 |
242
+ |---|---|
243
+ | `VITE_ONE_POP_API_BASE_URL` / `NEXT_PUBLIC_ONE_POP_API_BASE_URL` | one-pop-api base URL override |
244
+ | `VITE_ONE_POP_ENVIRONMENT` / `NEXT_PUBLIC_ONE_POP_ENVIRONMENT` | `dev`\|`stage`\|`production` (기본 production) |
245
+ | `ONE_POP_ENVIRONMENT` | 위 environment의 서버 전용(SSR, 접두사 없음) 변형 |
246
+ | `VITE_CROSS_AUTH_URL` / `NEXT_PUBLIC_CROSS_AUTH_URL` | cross-auth base URL override |
247
+
248
+ override 미지정 시 environment로 기본 URL을 고른다. base URL은 https(또는 `http://localhost`)만 허용.
249
+
250
+ ## 에러
251
+
252
+ 모든 실패는 `SafeDropError`로 던져진다. **`err.code`로 분기**하고 메시지 문자열에 의존하지 말라.
253
+ 백엔드 `code_name`은 아래 코드로 매핑된다(미매핑·5xx는 `API_ERROR`, `err.details`에 `status`/`code`/`codeName`).
254
+
255
+ | `code` | 의미 / 조치 |
256
+ |---|---|
257
+ | `UNAUTHORIZED` | SIWE JWT 없음·만료 → SIWE 재로그인 (`getJwt` 확인) |
258
+ | `INVALID_OAUTH_TOKEN` | X 토큰 scope 부족(`tweet.read`+`users.read`)·app-only → 재인증 |
259
+ | `X_CONNECTION_NOT_FOUND` | `connectX()` 먼저 (`listDrops`도 이 코드로 실패) |
260
+ | `WALLET_NOT_FOUND` | 해당 (sender, handle)의 pending 지갑 없음 — 이미 수령/환불 |
261
+ | `PENDING_LIMIT_EXCEEDED` | pending 드롭 상한 초과 → 수령/만료 대기 안내 |
262
+ | `SEND_BLOCKED` | 백엔드가 차단한 송금자 → 재시도 금지, 노출만 |
263
+ | `ENVELOPE_NOT_FOUND` | `envelopeId`가 낡음 → `listEnvelopes()` 재조회 |
264
+ | `INVALID_PARAM` | 요청 값 오류(예: `message` 140 runes 초과) |
265
+ | `RATE_LIMITED` | 백오프 후 재시도 |
266
+ | `MISSING_*` / `INVALID_AMOUNT` / `INVALID_TOKEN` | 네트워크·체인 호출 전 클라이언트 검증 실패 |
267
+ | `CHAIN_ERROR` | tx revert·RPC 실패. `details.revertName`에 디코드된 custom error |
268
+ | `API_ERROR` | 미매핑 백엔드 실패, 또는 주입한 `apiPort`가 해당 메서드 미구현 |
269
+
270
+ ## Advanced
271
+
272
+ 어댑터를 직접 조립하려면 `createSafeDrop(config, { api, chain, signer, crypto })`에 포트 구현을 주입한다. 테스트에서는 `SafeDropApiPort`를 mock으로 넘겨(`createSafeDropClient({ apiPort })`) 네트워크 없이 검증한다. 설계: `docs/pop/01-architecture.md`, 외부 서비스 통합: `docs/pop/03-integration.md`.
273
+
274
+ ## Appendix — 어댑터 옵션 레퍼런스
275
+
276
+ `createSafeDropClient`가 조립하는 어댑터들. 개별로 쓰거나 옵션을 세밀 조정할 때 참고.
277
+
278
+ ### `createSafeDropClient(options)` → `SafeDrop`
279
+
280
+ | 옵션 | 타입 | 기본 | 설명 |
281
+ |---|---|---|---|
282
+ | `contractAddress` | `Address` | — (필수) | SafeDrop 컨트랙트 주소 |
283
+ | `publicClient` | viem `PublicClient` | — (필수) | 읽기·getDrop·digest |
284
+ | `walletClient` | viem `WalletClient` | — (필수) | approve/deposit/refund 서명(연결 지갑) |
285
+ | `chain` | viem `Chain` | — (필수) | 대상 체인 |
286
+ | `claimBaseUrl` | `string` | — (필수) | 수령 링크 base URL |
287
+ | `transport` | viem `Transport` | `http()`(chain.rpcUrls) | withdraw 임시 지갑 client RPC |
288
+ | `withdrawFees` | `{ gas, maxFeePerGas, maxPriorityFeePerGas }` | 아래 gas-abstraction 기본 | withdraw gas/fee override |
289
+ | `api` | `HttpSafeDropApiAdapterOptions` | — | one-pop-api 어댑터 옵션(아래) |
290
+ | `apiPort` | `SafeDropApiPort` | — | 직접 구현/mock 주입(지정 시 `api` 무시) |
291
+
292
+ ### `HttpSafeDropApiAdapter` (`options.api`)
293
+
294
+ | 옵션 | 타입 | 기본 | 설명 |
295
+ |---|---|---|---|
296
+ | `baseUrl` | `string` | 환경변수(`getOnePopApiBaseUrl`) | one-pop-api base URL |
297
+ | `getJwt` | `() => string \| undefined \| Promise<…>` | — | **SIWE JWT.** deposit·`listDrops`·`listHistories`·`x-connections`·batch 서명에 필요. 미제공 시 요청 전에 `UNAUTHORIZED` |
298
+ | `fetchImpl` | `typeof fetch` | `globalThis.fetch` | 테스트/SSR 주입 |
299
+ | `paths` | `Partial<PopApiPaths>` | `DEFAULT_POP_API_PATHS` (`/wallets`, `/wallets/private-key`, `/drops`, `/envelopes`, `/leaderboards`, `/histories`, `/x-connections`, `/batch-claim-signature`, `/events/subscribe`) | 엔드포인트 경로 override |
300
+
301
+ ### `ViemSafeDropChainAdapter.withdrawFees` 기본값
302
+
303
+ 가스 대납(sponsor 과금)을 위한 type-2 dynamic-fee 값. 잔고 0 임시 지갑도 통과. `estimateGas`가 잔고 0에서 실패하므로 gas limit은 고정:
304
+
305
+ ```
306
+ gas: 300_000n
307
+ maxFeePerGas: 4_000_000_000n // 4 gwei
308
+ maxPriorityFeePerGas: 1_000_000_000n // 1 gwei (노드 최소 tip)
309
+ ```
310
+
311
+ override는 정상 fee(tip ≥ 1 gwei) + type-2를 유지해야 sponsor 과금이 동작한다.
312
+
313
+ ### `CrossAuthClient(options)` — SIWE JWT 발급 (`getJwt`용)
314
+
315
+ | 옵션 | 타입 | 기본 | 설명 |
316
+ |---|---|---|---|
317
+ | `baseUrl` | `string` | 환경변수(`getCrossAuthBaseUrl`) | cross-auth base URL |
318
+ | `domain` | `string` | `globalThis.location.origin` | SIWE domain |
319
+ | `fetchImpl` | `typeof fetch` | `globalThis.fetch` | 주입 |
320
+
321
+ ### `XAuthClient(options)` — X OAuth2 PKCE
322
+
323
+ | 옵션 | 타입 | 기본 | 설명 |
324
+ |---|---|---|---|
325
+ | `clientId` | `string` | — (필수) | X OAuth2 client id |
326
+ | `redirectUri` | `string` | — (필수) | 콜백 URL(수령 라우트) |
327
+ | `port` | `XAuthPort` | — (필수) | 토큰 교환/핸들 조회(`HttpXAuthAdapter`) |
328
+ | `scope` | `string` | `tweet.read users.read offline.access` | OAuth scope |
329
+ | `authorizeUrl` | `string` | `https://x.com/i/oauth2/authorize` | authorize 엔드포인트 |
330
+ | `storage` | `SessionStore` | `globalThis.sessionStorage` | verifier/state 임시 저장 |
331
+
332
+ ### `HttpXAuthAdapter(options)` — X 토큰/핸들 프록시
333
+
334
+ 앱의 same-origin 서버 라우트(토큰 교환 시 X client secret은 서버에서만)를 호출:
335
+
336
+ | 옵션 | 타입 | 기본 | 설명 |
337
+ |---|---|---|---|
338
+ | `baseUrl` | `string` | `''`(same-origin) | 프록시 base |
339
+ | `tokenPath` | `string` | `/api/x/token` | `POST {clientId,redirectUri,code,verifier}` → X 토큰 응답 |
340
+ | `mePath` | `string` | `/api/x/me` | `GET` (Bearer) → `GET /2/users/me`(`{data:{username}}`) |
341
+ | `fetchImpl` | `typeof fetch` | `globalThis.fetch` | 주입 |
342
+
343
+ `ViemClaimSignerAdapter`·`ViemCryptoAdapter`는 옵션이 없다(순수 서명/암호 연산).
@@ -0,0 +1,29 @@
1
+ /**
2
+ * X(Twitter) OAuth2 인증 계약. api.x.com은 CORS를 막으므로 실제 토큰 교환/핸들
3
+ * 조회는 앱의 same-origin 프록시(Next 라우트 `/api/x/token`, `/api/x/me`)를
4
+ * 경유한다 — 이 포트는 그 프록시 호출을 추상화한다. one-pop-api 미경유.
5
+ */
6
+ interface XTokenResult {
7
+ accessToken: string;
8
+ scope?: string;
9
+ expiresIn?: number;
10
+ refreshToken?: string;
11
+ }
12
+ interface XAuthPort {
13
+ /**
14
+ * authorization code + PKCE verifier를 access token으로 교환 (프록시 경유).
15
+ * 프록시(/api/x/token)가 grant_type/Basic auth 조립을 담당하므로 clientId도 함께 전달.
16
+ */
17
+ exchangeCode(params: {
18
+ clientId: string;
19
+ redirectUri: string;
20
+ code: string;
21
+ codeVerifier: string;
22
+ }): Promise<XTokenResult>;
23
+ /** access token 소유자의 X 핸들 조회 (GET /2/users/me 프록시). */
24
+ getHandle(accessToken: string): Promise<{
25
+ handle: string;
26
+ }>;
27
+ }
28
+
29
+ export type { XAuthPort as X, XTokenResult as a };