@nexus-cross/pop 1.3.10-beta.2 → 1.4.0-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 CHANGED
@@ -80,7 +80,6 @@ const safeDrop = createSafeDropClient({
80
80
  walletClient,
81
81
  chain,
82
82
  claimBaseUrl: 'https://app.example/claim',
83
- permit: false, // true only if BOTH the deployment and the token support EIP-2612
84
83
  api: { getJwt: () => token },
85
84
  });
86
85
  ```
@@ -94,7 +93,7 @@ The provider only carries the client through context — no connection logic liv
94
93
 
95
94
  ```ts
96
95
  const envelopes = await safeDrop.listEnvelopes(); // optional card designs
97
- const { withdrawLink, claimAddress } = await safeDrop.deposit({
96
+ const deposit = await safeDrop.deposit({
98
97
  sender: address,
99
98
  recipient: { provider: 'x', handle: 'theo_13303' },
100
99
  token: ERC20_ADDRESS,
@@ -102,23 +101,25 @@ const { withdrawLink, claimAddress } = await safeDrop.deposit({
102
101
  message: 'Happy birthday!', // optional, <= 140 runes
103
102
  envelopeId: envelopes[0]?.id, // optional
104
103
  });
104
+ if (!deposit.isMapped) {
105
+ location.assign(buildComposeUrl(buildDmText(deposit.withdrawLink), recipientId));
106
+ }
105
107
  ```
106
108
 
107
- Then hand the link to the recipient (X DM compose):
108
- `location.assign(buildComposeUrl(buildDmText(withdrawLink), recipientId))`.
109
+ Mapped deposits have no claim link because the mapped recipient receives them directly.
109
110
 
110
111
  ### Claim (recipient, on the claim route)
111
112
 
112
113
  ```ts
113
- const { sender, secret } = parseWithdrawLink(location.href);
114
+ const { sender, claimAddress, secret } = parseWithdrawLink(location.href);
114
115
  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 });
116
+ const claim = await safeDrop.retrieveClaimKey({ claimAddress: claimAddress!, senderAddress: sender!, oauth });
117
+ if (claim.isMapped) throw new Error('Use the mapped withdrawal flow');
118
+ await safeDrop.withdraw({ recipient: myAddress, sponsor: sender!, claimAddress, secret: secret!, claimKey: claim.claimKey });
117
119
  ```
118
120
 
119
121
  `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
+ `claimAddress` selects the exact pending drop; OAuth proves entitlement to that drop's handle.
122
123
 
123
124
  ### Inbox / social surfaces
124
125
 
@@ -126,8 +127,8 @@ It returns **only the latest pending drop** for that `(sender, handle)` pair and
126
127
  await safeDrop.connectX({ oauth }); // required once before listDrops
127
128
  const conn = await safeDrop.getXConnection(); // null when not connected
128
129
  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 });
130
+ const top = await safeDrop.getLeaderboard({ token: ERC20_ADDRESS, page: 1, pageSize: 10 });
131
+ const page = await safeDrop.listHistories({ type: 'received', page: 1, pageSize: 20 });
131
132
  await safeDrop.disconnectX();
132
133
  ```
133
134
 
@@ -137,7 +138,7 @@ batch-claim CTA on it.
137
138
  ### Refund
138
139
 
139
140
  ```ts
140
- await safeDrop.refund({ claimAddress }); // sponsor only, after expiry
141
+ await safeDrop.refund({ claimAddress, sponsor: address });
141
142
  ```
142
143
 
143
144
  ### Batch claim — drain every drop for a handle in one flow
@@ -146,33 +147,25 @@ await safeDrop.refund({ claimAddress }); // sponsor only, after expiry
146
147
  const { txHashes, recipient } = await safeDrop.batchClaim({
147
148
  id: myHandle, // normalized internally
148
149
  oauth, // X OAuth proof (handle ownership)
149
- maxCount: 30, // optional page size, default 30
150
+ sponsor: sender,
151
+ claimAddress,
152
+ claimKey,
153
+ secret,
150
154
  });
151
155
  ```
152
156
 
153
157
  `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
+ resolve the anchor and pending drop keys → read `validatorNonceById(keccak256(utf8(id)))` →
159
+ request and verify the backend signaturecall `withdrawUnmappedBatchByKeys` once.
158
160
 
159
161
  Rules specific to this path:
160
162
 
161
163
  - **The recipient is the SIWE-authenticated address**, not a parameter you choose. Passing a
162
164
  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.
165
+ - The anchor temporary wallet submits the transaction with the configured sponsored fee path.
166
+ - The anchor drop's `secret` and `claimKey` are required in addition to the validator signature.
167
+ - A successful batch consumes the nonce and signature. Request a fresh signature for another call.
168
+ - Each page gets a fresh nonce and validator signature; `maxCount` defaults to 30.
176
169
  - On `SIGN_FAILED` the backend signed something the contract will reject — a different EIP-712
177
170
  domain/type, a stale nonce, or a `personal_sign` prefix. Do not retry; report it.
178
171
 
@@ -182,8 +175,8 @@ The on-chain contract (verified against deployed bytecode, CROSS testnet 612044)
182
175
  domain { name: 'ONEpop', version: '1', chainId, verifyingContract: <SafeDrop> }
183
176
  type ValidatorClaim(address recipient,string id,uint256 nonce,uint256 deadline)
184
177
  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)
178
+ submit withdrawUnmappedBatchByKeys(recipient, id, anchorDropKey, claimSignature,
179
+ secret, nonce, deadline, dropKeys, validatorSignature)
187
180
  errors ValidatorSigExpired() → deadline passed | InvalidValidatorNonce() → stale nonce
188
181
  ECDSAInvalidSignature() → signer is not validator()
189
182
  ```
@@ -207,33 +200,31 @@ The SDK does not wrap it — use `EventSource` directly. Delivery is best-effort
207
200
  logs, no analytics, no backend call. Sending it collapses the trust split.
208
201
  3. **`getJwt` is mandatory** for `deposit`, `listDrops`, `listHistories`, and all `x-connections`
209
202
  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.
203
+ 4. **The current deployment requires EIP-2612 permit.** The token must support EIP-2612.
204
+ 5. **Keep `transactionFees` as EIP-1559 with tip 1 gwei.** Every write supplies an explicit
205
+ gas limit. Batch gas is `batchBaseGas + batchGasPerDrop * count` and may not exceed
206
+ `maxBatchGas`; calibrate these values below the chain block gas limit.
214
207
  6. **`message` ≤ 140 runes** (code points) and `envelopeId` must come from `listEnvelopes()`;
215
208
  otherwise the backend rejects with `INVALID_PARAM` / `ENVELOPE_NOT_FOUND`.
216
209
  7. **Handles are normalized** by the SDK (`normalizeHandle`); pass the same handle to deposit and
217
210
  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.
211
+ 8. **Drops are keyed by bytes32.** Resolve `activeDropOf(claimAddress)` before `drops(dropKey)`.
221
212
 
222
213
  ## 7. API surface
223
214
 
224
215
  | `safeDrop` method | Endpoint / chain call | Auth |
225
216
  |---|---|---|
226
- | `deposit` | `POST /wallets` + `approve`/`deposit` (or permit deposit) | JWT |
217
+ | `deposit` | `POST /wallets` + permit `deposit` | JWT |
227
218
  | `retrieveClaimKey` | `POST /wallets/private-key` | X OAuth token (body) |
228
219
  | `withdraw` | `withdraw(...)` from the claim wallet | claim key |
229
- | `refund` | `refund(claimAddr)` | sponsor wallet |
230
- | `getDrop` | `drops(claimAddr)` | — |
220
+ | `refund` | `activeDropOf(claimAddr)` + `refund(dropKey)` | sponsor wallet |
221
+ | `getDrop` | `activeDropOf(claimAddr)` + `drops(dropKey)` | — |
231
222
  | `listDrops` | `GET /drops` | JWT + active X connection |
232
223
  | `listEnvelopes` | `GET /envelopes` | public |
233
224
  | `getLeaderboard` | `GET /leaderboards?token=` | public |
234
225
  | `listHistories` | `GET /histories` | JWT |
235
226
  | `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 |
227
+ | `batchClaim` | `POST /batch-claim-signature` + `withdrawUnmappedBatchByKeys` | JWT + X OAuth token + anchor claim key |
237
228
  | `requestBatchClaimSignature` | `POST /batch-claim-signature` (low-level; prefer `batchClaim`) | JWT + X OAuth token |
238
229
 
239
230
  ## 8. Errors
@@ -265,4 +256,4 @@ Everything rejects with `SafeDropError` — branch on `err.code`, never on the m
265
256
  `buildWithdrawLink` (the `#fragment` placement is load-bearing).
266
257
  - Injecting a partial `SafeDropApiPort` mock and calling the query methods → `API_ERROR`
267
258
  ("not implemented"); implement the methods your test path touches.
268
- - Enabling `permit: true` on a deployment whose `deposit` has no permit signature.
259
+ - Using a token without EIP-2612 permit support.
package/README.md CHANGED
@@ -24,7 +24,7 @@ npm i @nexus-cross/pop viem # react는 선택
24
24
 
25
25
  ## 개념
26
26
 
27
- - **claimAddress** — 드롭당 임시 지갑 주소. 온체인 조회 키(`drops(claimAddress)`).
27
+ - **claimAddress** — 드롭당 임시 지갑 주소. `activeDropOf`로 현재 drop key를 찾는 데 사용.
28
28
  - **secret** — 평문 비밀 문구. **어떤 서버에도 전송되지 않고** 수령 링크의 `#fragment`에만 담긴다.
29
29
  - **withdrawLink** — `…/claim?sender=<addr>#<secret>`. 이 링크를 X DM으로 수령인에게 보낸다.
30
30
  - **claimKey** — 임시 claim 지갑의 개인키. 수령인이 X OAuth 검증 후 백엔드에서 받아 가스 대납 withdraw에 쓴다.
@@ -65,7 +65,9 @@ const { token } = await auth.siweToken(address, signature); // token = getJwt
65
65
  ## 1) Deposit — 토큰 보내기
66
66
 
67
67
  ```ts
68
- const { withdrawLink, claimAddress } = await safeDrop.deposit({
68
+ import { buildComposeUrl, buildDmText } from '@nexus-cross/pop';
69
+
70
+ const deposit = await safeDrop.deposit({
69
71
  sender: address, // 연결된 지갑 = 스폰서
70
72
  recipient: { provider: 'x', handle: 'theo_13303' },
71
73
  token: '0x…ERC20',
@@ -74,18 +76,16 @@ const { withdrawLink, claimAddress } = await safeDrop.deposit({
74
76
  envelopeId: 'classic-coral', // 선택 — listEnvelopes()의 id
75
77
  // secret 미지정 시 자동 생성
76
78
  });
79
+ if (!deposit.isMapped) {
80
+ location.assign(buildComposeUrl(buildDmText(deposit.withdrawLink), recipientId));
81
+ }
77
82
  ```
78
83
 
79
84
  `message`·`envelopeId`는 **백엔드에만 저장**되는 메타데이터다(컨트랙트에 필드 없음).
80
85
 
81
- 내부 흐름: 임시 claim 지갑 생성(백엔드) → `approve` → `deposit` → 링크 생성. `amount`는 **BigInt로 끝까지** 유지한다 (Number 변환 금지).
82
-
83
- 수령인에게 링크 전달 (X DM 작성창):
86
+ 내부 흐름: 임시 claim 지갑 생성(백엔드) → EIP-2612 permit 서명 → `deposit` → 링크 생성. `amount`는 **BigInt로 끝까지** 유지한다 (Number 변환 금지).
84
87
 
85
- ```ts
86
- import { buildDmText, buildComposeUrl } from '@nexus-cross/pop';
87
- location.assign(buildComposeUrl(buildDmText(withdrawLink), recipientId));
88
- ```
88
+ `isMapped: true`면 이미 매핑된 수령인으로 즉시 예치되어 claim link가 없다.
89
89
 
90
90
  ## 2) Withdraw — 수령인이 받기
91
91
 
@@ -95,7 +95,7 @@ location.assign(buildComposeUrl(buildDmText(withdrawLink), recipientId));
95
95
  import { parseWithdrawLink } from '@nexus-cross/pop';
96
96
  import { XAuthClient, HttpXAuthAdapter } from '@nexus-cross/pop/adapters';
97
97
 
98
- const { sender, secret } = parseWithdrawLink(location.href);
98
+ const { sender, claimAddress, secret } = parseWithdrawLink(location.href);
99
99
 
100
100
  // (a) X OAuth2 PKCE — 리다이렉트는 앱이 담당
101
101
  const xauth = new XAuthClient({
@@ -108,37 +108,38 @@ const xauth = new XAuthClient({
108
108
  const { oauth } = await xauth.complete(location.href);
109
109
 
110
110
  // (b) OAuth 검증 → 임시 claim 키/주소 수령 (point of return)
111
- const { claimKey, claimAddress } = await safeDrop.retrieveClaimKey({
111
+ const claim = await safeDrop.retrieveClaimKey({
112
+ claimAddress: claimAddress!,
112
113
  senderAddress: sender!,
113
114
  oauth,
114
115
  });
116
+ if (claim.isMapped) throw new Error('Use the mapped withdrawal flow');
115
117
 
116
118
  // (c) 가스 대납 withdraw — 임시 지갑이 직접 tx 전송
117
119
  const { txHash } = await safeDrop.withdraw({
118
120
  recipient: myReceivingAddress, // 수령인이 지정, 서명에 바인딩
121
+ sponsor: sender!,
119
122
  claimAddress,
120
123
  secret: secret!,
121
- claimKey,
124
+ claimKey: claim.claimKey,
122
125
  });
123
126
  ```
124
127
 
125
- ## 3) Refund — 만료 환불
128
+ ## 3) Refund — sponsor 환불
126
129
 
127
130
  ```ts
128
- await safeDrop.refund({ claimAddress }); // 만료 후 sponsor(msg.sender)만 가능
131
+ await safeDrop.refund({ claimAddress, sponsor: address });
129
132
  ```
130
133
 
131
134
  ## 조회 — 드롭 상태 (온체인)
132
135
 
133
136
  ```ts
134
- const drop = await safeDrop.getDrop(claimAddress); // 없으면 null
135
- // drop: DropState { claimAddress, sponsor, amount, secretHash, salt, id, token?, expiry? }
137
+ const drop = await safeDrop.getDrop(claimAddress, sponsor); // 없으면 null
138
+ // drop: DropState { dropKey, claimAddress, sponsor, recipient, amount, secretHash, id, token }
136
139
  ```
137
140
 
138
- ⚠️ `token`·`expiry`는 **optional**이다 배포본마다 `drops`의 반환 필드가 다르다(permit
139
- 배포본은 5개: sponsor/amount/secretHash/salt/id). 어댑터가 형태를 모두 디코딩하고
140
- `token`은 에스크로 `token()`으로 채우지만, **`expiry`는 5필드 배포본에서 `undefined`** 다.
141
- 만료 판정 전에 존재 여부를 확인하라(`undefined` = 만료 아님이 아니라 *알 수 없음*).
141
+ 현재 컨트랙트는 `activeDropOf(claimAddress)`로 key를 찾고 `drops(dropKey)`를 조회한다.
142
+ 토큰은 드롭별 필드가 아니라 에스크로의 고정 `token()` 값이다.
142
143
 
143
144
  ## 4) 백엔드 조회 — 인박스 · X 연결 · 리더보드 · 히스토리
144
145
 
@@ -152,15 +153,15 @@ await safeDrop.disconnectX(); // idempotent
152
153
 
153
154
  // 내게 온 수령 대기 목록 (Bearer JWT + 활성 X 연결)
154
155
  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 }
156
+ // { items, count, totalAmount, hasClaimedBefore, mappedRecipient, pendingChangeTo }
157
+ // PendingDrop { id, dropKey, claimAddress, token, amount, message, envelopeId, depositedAt, sender }
157
158
 
158
159
  // 카드 디자인 · 리더보드 (공개 — JWT 불필요)
159
160
  const envelopes = await safeDrop.listEnvelopes({ locale: 'ko' });
160
- const top = await safeDrop.getLeaderboard({ token: '0x…ERC20' });
161
+ const top = await safeDrop.getLeaderboard({ token: '0x…ERC20', page: 1, pageSize: 10 });
161
162
 
162
- // 내 온체인 이벤트 (Deposited | Withdrawn | Refunded | BatchWithdrawn)
163
- const page = await safeDrop.listHistories({ event: 'Withdrawn', page: 1, pageSize: 20 });
163
+ // 내 drop 히스토리
164
+ const page = await safeDrop.listHistories({ type: 'received', page: 1, pageSize: 20 });
164
165
  ```
165
166
 
166
167
  `inbox.hasClaimedBefore === false`면 **첫 수령은 개별 claim만** 가능하다 — 일괄 수령 CTA를 이 값으로 게이트한다.
@@ -169,26 +170,25 @@ const page = await safeDrop.listHistories({ event: 'Withdrawn', page: 1, pageSiz
169
170
 
170
171
  ```ts
171
172
  const { txHashes, recipient } = await safeDrop.batchClaim({
172
- id: myHandle, // 내부에서 normalizeHandle
173
- oauth, // 핸들 소유 증명 (X OAuth)
174
- maxCount: 30, // 선택 — tx 1건이 처리할 건수(기본 30)
173
+ id: myHandle,
174
+ oauth,
175
+ sponsor: sender,
176
+ claimAddress,
177
+ claimKey,
178
+ secret,
175
179
  });
176
180
  ```
177
181
 
178
- 컨트랙트가 강제하는 순서대로 진행한다:
179
- `validatorNonceById(keccak256(utf8(id)))` → 백엔드 서명 발급 온체인
180
- `validatorClaimDigest(recipient, id, nonce, deadline)` 조회 **서명이 `validator()`로
181
- 복원되는지 가스 쓰기 전에 검증** → `liveDropCountById(id)`가 0이 될 때까지
182
- `withdrawByValidator` 반복(서명 1건 재사용).
182
+ 컨트랙트가 강제하는 순서대로 진행한다: anchor drop key와 pending keys 조회 →
183
+ `validatorNonceById` → 백엔드 서명 서명자 검증
184
+ `withdrawUnmappedBatchByKeys` 1회 호출. validator 서명은 성공한 호출마다 소비된다.
183
185
 
184
186
  | 개별 수령과 다른 점 | 내용 |
185
187
  |---|---|
186
- | secret | **불필요** 게이트는 validator 서명이다 |
188
+ | secret | anchor drop의 secret/claim key가 필요하다 |
187
189
  | 수령 주소 | **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`) |
190
+ | tx 전송자 | anchor 임시 claim 지갑 |
191
+ | 서명 재사용 | 불가 nonce는 성공한 batch 호출마다 소비됨 |
192
192
 
193
193
  `SIGN_FAILED`가 나면 백엔드 서명이 컨트랙트가 기대하는 값과 다르다(EIP-712 domain/type 불일치,
194
194
  낡은 nonce, `personal_sign` 프리픽스). 재시도해도 동일하게 실패하니 그대로 노출한다.
@@ -200,9 +200,8 @@ const { txHashes, recipient } = await safeDrop.batchClaim({
200
200
  domain { name: 'ONEpop', version: '1', chainId, verifyingContract: <SafeDrop> }
201
201
  type ValidatorClaim(address recipient,string id,uint256 nonce,uint256 deadline)
202
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)
203
+ submit withdrawUnmappedBatchByKeys(recipient, id, anchorDropKey, claimSignature,
204
+ secret, nonce, deadline, dropKeys, validatorSignature)
206
205
  검증 순서 deadline(ValidatorSigExpired) → nonce(InvalidValidatorNonce) → 서명(ECDSAInvalidSignature)
207
206
  ```
208
207
 
@@ -211,8 +210,7 @@ submit withdrawByValidator(address recipient, string id, uint256 nonce,
211
210
  두 조건을 클라이언트에서 선제 차단하지만 온체인 강제의 대체물은 아니다 —
212
211
  `docs/pop/02-frontend-dependencies.md#검증-한계-중요` 참조.
213
212
 
214
- 함수들은 `safedrop_abi.json`(Foundry 산출물, 배포본과 불일치)에 없다 — SDK는
215
- `adapters/validatorAbi.ts`에 바이트코드로 검증한 ABI를 따로 들고 있다.
213
+ 전체 현재 ABI는 `safedrop_abi.json`에서 직접 export된다.
216
214
  서명만 직접 받아 쓰려면 저수준 `safeDrop.requestBatchClaimSignature({ id, oauth, nonce, deadline })`.
217
215
  </details>
218
216
 
@@ -281,11 +279,12 @@ override 미지정 시 environment로 기본 URL을 고른다. base URL은 https
281
279
  |---|---|---|---|
282
280
  | `contractAddress` | `Address` | — (필수) | SafeDrop 컨트랙트 주소 |
283
281
  | `publicClient` | viem `PublicClient` | — (필수) | 읽기·getDrop·digest |
284
- | `walletClient` | viem `WalletClient` | — (필수) | approve/deposit/refund 서명(연결 지갑) |
282
+ | `walletClient` | viem `WalletClient` | — (필수) | deposit/refund 서명(연결 지갑) |
285
283
  | `chain` | viem `Chain` | — (필수) | 대상 체인 |
286
284
  | `claimBaseUrl` | `string` | — (필수) | 수령 링크 base URL |
287
285
  | `transport` | viem `Transport` | `http()`(chain.rpcUrls) | withdraw 임시 지갑 client RPC |
288
- | `withdrawFees` | `{ gas, maxFeePerGas, maxPriorityFeePerGas }` | 아래 gas-abstraction 기본 | withdraw gas/fee override |
286
+ | `transactionFees` | `SafeDropTransactionFees` | 아래 gas-abstraction 기본 | 모든 write의 EIP-1559/gas override |
287
+ | `withdrawFees` | `SafeDropTransactionFees` | — | deprecated alias |
289
288
  | `api` | `HttpSafeDropApiAdapterOptions` | — | one-pop-api 어댑터 옵션(아래) |
290
289
  | `apiPort` | `SafeDropApiPort` | — | 직접 구현/mock 주입(지정 시 `api` 무시) |
291
290
 
@@ -298,17 +297,23 @@ override 미지정 시 environment로 기본 URL을 고른다. base URL은 https
298
297
  | `fetchImpl` | `typeof fetch` | `globalThis.fetch` | 테스트/SSR 주입 |
299
298
  | `paths` | `Partial<PopApiPaths>` | `DEFAULT_POP_API_PATHS` (`/wallets`, `/wallets/private-key`, `/drops`, `/envelopes`, `/leaderboards`, `/histories`, `/x-connections`, `/batch-claim-signature`, `/events/subscribe`) | 엔드포인트 경로 override |
300
299
 
301
- ### `ViemSafeDropChainAdapter.withdrawFees` 기본값
300
+ ### `ViemSafeDropChainAdapter.transactionFees` 기본값
302
301
 
303
- 가스 대납(sponsor 과금)을 위한 type-2 dynamic-fee 값. 잔고 0 임시 지갑도 통과. `estimateGas`가 잔고 0에서 실패하므로 gas limit은 고정:
302
+ 모든 write는 `type: 'eip1559'`와 gas limit을 명시해 wallet/RPC gas estimation을 피한다.
303
+ batch gas는 `batchBaseGas + batchGasPerDrop × count`이며 `maxBatchGas` 초과 시 전송 전에 실패한다.
304
304
 
305
305
  ```
306
- gas: 300_000n
306
+ gas: 1_000_000n
307
+ batchBaseGas: 1_000_000n
308
+ batchGasPerDrop: 750_000n
309
+ maxBatchGas: 30_000_000n
307
310
  maxFeePerGas: 4_000_000_000n // 4 gwei
308
311
  maxPriorityFeePerGas: 1_000_000_000n // 1 gwei (노드 최소 tip)
309
312
  ```
310
313
 
311
- override는 정상 fee(tip 1 gwei) + type-2를 유지해야 sponsor 과금이 동작한다.
314
+ 기본 batch 30건은 `23_500_000` gas다(CROSS testnet block gas limit `105_000_000` 아래).
315
+ 실제 배포 환경의 block gas limit과 실행량에 맞춰 네 값을 함께 조정해야 하며,
316
+ tip ≥ 1 gwei의 type-2를 유지해야 sponsor 과금이 동작한다.
312
317
 
313
318
  ### `CrossAuthClient(options)` — SIWE JWT 발급 (`getJwt`용)
314
319