@cloakely/sdk 0.8.0 → 0.8.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/dist/drops.js ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Sealed NFT drops: one collection and its sealed sale (CloakDrop), made by CloakDropFactory.
3
+ *
4
+ * GTD listed wallets mint their guaranteed spots at the creator's GTD price, until the GTD window closes.
5
+ * Auction everyone else bids what they'd pay, sealed to a drand round. The creator's price is the floor. Bids rank
6
+ * by score: their price, or for WL bids their price × (1 + boost). Public winners pay the clearing score;
7
+ * WL winners pay it ÷ (1 + boost), never under the floor. Bids tied at the clearing score share what's left
8
+ * by a lottery whose random start comes from every bid's sealed salt.
9
+ *
10
+ * The GTD and WL lists live on chain only as Merkle roots. The lists themselves are published once, in the factory's
11
+ * DropDetails event, so anyone can rebuild a proof; a list that doesn't match the root proves nothing.
12
+ */
13
+ import { encodeAbiParameters, getAddress, isAddress, keccak256, toHex } from "viem";
14
+ import { cloakDropAbi, cloakDropFactoryAbi } from "./abis.js";
15
+ import { commitmentOf } from "./cloak.js";
16
+ import { drandRoundAt, drandRoundTime, sealBid, unsealBid } from "./seal.js";
17
+ export const DROP_BPS = 10000n;
18
+ export const DROP_MAX_BOOST_BPS = 3_000;
19
+ export const DROP_MAX_PER_BID = 20;
20
+ export const DROP_MAX_SUPPLY = 10_000;
21
+ /** Most entries one GTD or WL list can hold (the lists travel in one transaction). */
22
+ export const DROP_LIST_MAX = 2_000;
23
+ /** How long the keeper has to open the bids at most. Settlement doesn't wait for it once every bid is open. */
24
+ export const DROP_DEFAULT_REVEAL_SECS = 1_800;
25
+ // ───────────────────────────── Merkle lists ─────────────────────────────
26
+ const enc = (types, values) => encodeAbiParameters(types, values);
27
+ /** A WL list entry, as the drop hashes it. */
28
+ export const wlLeaf = (wallet) => keccak256(keccak256(enc([{ type: "address" }], [wallet])));
29
+ /** A GTD list entry: a wallet and its number of spots. */
30
+ export const gtdLeaf = (wallet, spots) => keccak256(keccak256(enc([{ type: "address" }, { type: "uint32" }], [wallet, spots])));
31
+ const hashPair = (a, b) => BigInt(a) < BigInt(b) ? keccak256(enc([{ type: "bytes32" }, { type: "bytes32" }], [a, b])) : keccak256(enc([{ type: "bytes32" }, { type: "bytes32" }], [b, a]));
32
+ /** A Merkle tree over `leaves` (sorted-pair hashing, the layout CloakDrop checks). */
33
+ export function merkleTree(leaves) {
34
+ const base = [...new Set(leaves.map((l) => l.toLowerCase()))].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
35
+ if (!base.length)
36
+ return { root: `0x${"0".repeat(64)}`, proof: () => [] };
37
+ const layers = [base];
38
+ while (layers.at(-1).length > 1) {
39
+ const prev = layers.at(-1);
40
+ const next = [];
41
+ for (let i = 0; i < prev.length; i += 2)
42
+ next.push(i + 1 < prev.length ? hashPair(prev[i], prev[i + 1]) : prev[i]);
43
+ layers.push(next);
44
+ }
45
+ return {
46
+ root: layers.at(-1)[0],
47
+ proof(leaf) {
48
+ let i = layers[0].indexOf(leaf.toLowerCase());
49
+ if (i < 0)
50
+ throw new Error("not on the list");
51
+ const out = [];
52
+ for (let k = 0; k < layers.length - 1; k++) {
53
+ const sib = i ^ 1;
54
+ if (sib < layers[k].length)
55
+ out.push(layers[k][sib]);
56
+ i = Math.floor(i / 2);
57
+ }
58
+ return out;
59
+ },
60
+ };
61
+ }
62
+ const PHASES = ["gtd", "bidding", "reveal", "settle", "numbering", "settled"];
63
+ // ───────────────────────────── client ─────────────────────────────
64
+ export class CloakDrops {
65
+ publicClient;
66
+ walletClient;
67
+ factory;
68
+ fromBlock;
69
+ constructor(publicClient, walletClient, factory,
70
+ /** First block to read the factory's and drops' events from. */
71
+ fromBlock = 0n) {
72
+ this.publicClient = publicClient;
73
+ this.walletClient = walletClient;
74
+ this.factory = factory;
75
+ this.fromBlock = fromBlock;
76
+ }
77
+ /** Many reads in one multicall where the chain has multicall3, else one by one (a local chain). */
78
+ async reads(contracts) {
79
+ if (!contracts.length)
80
+ return [];
81
+ if (this.publicClient.chain?.contracts?.multicall3)
82
+ return this.publicClient.multicall({ contracts: contracts, allowFailure: false });
83
+ return Promise.all(contracts.map((c) => this.publicClient.readContract(c)));
84
+ }
85
+ wallet() {
86
+ if (!this.walletClient)
87
+ throw new Error("CloakDrops: a walletClient is required for this call");
88
+ return this.walletClient;
89
+ }
90
+ async send(hash) {
91
+ const r = await this.publicClient.waitForTransactionReceipt({ hash });
92
+ if (r.status !== "success")
93
+ throw new Error(`transaction reverted: ${hash}`);
94
+ return r;
95
+ }
96
+ /** Every drop the factory made, newest last. */
97
+ async list() {
98
+ const n = Number(await this.publicClient.readContract({ address: this.factory, abi: cloakDropFactoryAbi, functionName: "dropsCount" }));
99
+ if (!n)
100
+ return [];
101
+ return (await this.reads(Array.from({ length: n }, (_, i) => ({ address: this.factory, abi: cloakDropFactoryAbi, functionName: "drops", args: [BigInt(i)] }))));
102
+ }
103
+ /** Create a drop. The caller is its creator. Returns the drop's address. */
104
+ async create(o) {
105
+ const w = this.wallet();
106
+ if (!o.name.trim() || o.name.length > 64 || !o.symbol.trim() || o.symbol.length > 16)
107
+ throw new Error("Give the collection a name (up to 64 characters) and a symbol (up to 16).");
108
+ if (!Number.isInteger(o.supply) || o.supply < 1 || o.supply > DROP_MAX_SUPPLY)
109
+ throw new Error(`Supply is 1 to ${DROP_MAX_SUPPLY}.`);
110
+ if (o.floor <= 0n)
111
+ throw new Error("Set a price above zero.");
112
+ const boost = o.wl?.boostBps ?? 0;
113
+ if (boost < 0 || boost > DROP_MAX_BOOST_BPS)
114
+ throw new Error("The WL boost is 0% to 30%.");
115
+ if ((o.gtd?.list.length ?? 0) > DROP_LIST_MAX || (o.wl?.list.length ?? 0) > DROP_LIST_MAX)
116
+ throw new Error(`A list holds up to ${DROP_LIST_MAX} wallets.`);
117
+ const gtdList = (o.gtd?.list ?? []).map((e) => ({ wallet: getAddress(e.wallet), spots: e.spots }));
118
+ const wlList = (o.wl?.list ?? []).map((a) => getAddress(a));
119
+ for (const e of gtdList)
120
+ if (!Number.isInteger(e.spots) || e.spots < 1)
121
+ throw new Error(`Give ${e.wallet} at least one GTD spot.`);
122
+ const gtdSpots = gtdList.reduce((a, e) => a + e.spots, 0);
123
+ if (gtdSpots > o.supply)
124
+ throw new Error("There are more GTD spots than NFTs.");
125
+ const now = Number((await this.publicClient.getBlock()).timestamp);
126
+ const gtdEnd = now + 90 + (gtdList.length ? o.gtd.durationSecs : 0);
127
+ const commitEnd = gtdEnd + o.biddingSecs;
128
+ const tlockRound = drandRoundAt(commitEnd);
129
+ const revealEnd = Math.max(commitEnd + (o.revealSecs ?? DROP_DEFAULT_REVEAL_SECS), drandRoundTime(tlockRound) + 60);
130
+ const params = {
131
+ creator: w.account.address,
132
+ supply: o.supply,
133
+ maxPerBid: o.maxPerBid ?? 2,
134
+ numTicks: o.numTicks ?? 200,
135
+ wlBoostBps: wlList.length ? boost : 0,
136
+ royaltyBps: o.royaltyBps ?? 500,
137
+ gtdEnd: BigInt(gtdEnd),
138
+ commitEnd: BigInt(commitEnd),
139
+ revealEnd: BigInt(revealEnd),
140
+ tlockRound: BigInt(tlockRound),
141
+ floor: o.floor,
142
+ tickSize: o.tickSize ?? (o.floor / 20n || 1n),
143
+ gtdPrice: o.gtd?.price ?? 0n,
144
+ gtdRoot: merkleTree(gtdList.map((e) => gtdLeaf(e.wallet, e.spots))).root,
145
+ wlRoot: merkleTree(wlList.map(wlLeaf)).root,
146
+ };
147
+ const details = { v: 1, ...(o.image ? { image: o.image } : {}), ...(o.description ? { description: o.description.slice(0, 1000) } : {}), ...(gtdList.length ? { gtd: gtdList } : {}), ...(wlList.length ? { wl: wlList } : {}), ...(o.spots ? { spots: { partner: o.spots.partner.slice(0, 200) } } : {}) };
148
+ const { result, request } = await this.publicClient.simulateContract({
149
+ address: this.factory,
150
+ abi: cloakDropFactoryAbi,
151
+ functionName: "createDrop",
152
+ args: [params, o.name.trim(), o.symbol.trim(), o.baseURI.trim(), JSON.stringify(details)],
153
+ account: w.account,
154
+ });
155
+ const txHash = await w.writeContract(request);
156
+ await this.send(txHash);
157
+ return { drop: result, txHash };
158
+ }
159
+ /** A drop's published details: its image, description and the lists behind its roots. */
160
+ async details(drop) {
161
+ const logs = await this.chunked((from, to) => this.publicClient.getContractEvents({ address: this.factory, abi: cloakDropFactoryAbi, eventName: "DropDetails", args: { drop }, fromBlock: from, toBlock: to, strict: true }), true);
162
+ try {
163
+ const d = JSON.parse(logs[0]?.args.details ?? "{}");
164
+ return {
165
+ image: typeof d.image === "string" ? d.image : undefined,
166
+ description: typeof d.description === "string" ? d.description : undefined,
167
+ gtd: Array.isArray(d.gtd) ? d.gtd.filter((e) => isAddress(e?.wallet) && Number.isInteger(e?.spots)) : undefined,
168
+ wl: Array.isArray(d.wl) ? d.wl.filter((a) => isAddress(a)) : undefined,
169
+ spots: typeof d.spots?.partner === "string" ? { partner: d.spots.partner } : undefined,
170
+ };
171
+ }
172
+ catch {
173
+ return {};
174
+ }
175
+ }
176
+ async get(drop) {
177
+ const c = { address: drop, abi: cloakDropAbi };
178
+ const fn = ["params", "name", "symbol", "baseURI", "phase", "bidsCount", "revealCount", "gtdMinted", "auctionSupply", "totalSupply", "settled", "failed", "sold", "clearingScore", "tieSupply", "tieDemand"];
179
+ const r = await this.reads(fn.map((f) => ({ ...c, functionName: f })));
180
+ const p = r[0];
181
+ const settled = r[10];
182
+ const clearing = r[13];
183
+ const zero = `0x${"0".repeat(64)}`;
184
+ const publicPrice = settled ? (clearing + DROP_BPS - 1n) / DROP_BPS : 0n;
185
+ let wlPrice = settled ? (clearing + DROP_BPS + BigInt(p.wlBoostBps) - 1n) / (DROP_BPS + BigInt(p.wlBoostBps)) : 0n;
186
+ if (settled && wlPrice < p.floor)
187
+ wlPrice = p.floor;
188
+ return {
189
+ address: drop,
190
+ name: r[1],
191
+ symbol: r[2],
192
+ baseURI: r[3],
193
+ creator: p.creator,
194
+ supply: p.supply,
195
+ maxPerBid: p.maxPerBid,
196
+ numTicks: p.numTicks,
197
+ wlBoostBps: p.wlBoostBps,
198
+ royaltyBps: p.royaltyBps,
199
+ gtdEnd: Number(p.gtdEnd),
200
+ commitEnd: Number(p.commitEnd),
201
+ revealEnd: Number(p.revealEnd),
202
+ tlockRound: Number(p.tlockRound),
203
+ floor: p.floor,
204
+ tickSize: p.tickSize,
205
+ gtdPrice: p.gtdPrice,
206
+ hasGtd: p.gtdRoot !== zero,
207
+ hasWl: p.wlRoot !== zero,
208
+ phase: PHASES[Number(r[4])],
209
+ bids: Number(r[5]),
210
+ reveals: Number(r[6]),
211
+ gtdMinted: Number(r[7]),
212
+ auctionSupply: Number(r[8]),
213
+ minted: Number(r[9]),
214
+ settled,
215
+ failed: r[11],
216
+ sold: Number(r[12]),
217
+ publicPrice,
218
+ wlPrice,
219
+ tieSupply: Number(r[14]),
220
+ tieDemand: Number(r[15]),
221
+ };
222
+ }
223
+ /** The bid level for a price: the highest level at or below it (bids snap down, never up). */
224
+ static tickFor(s, price) {
225
+ if (price < s.floor)
226
+ throw new Error("Bid at least the creator's price.");
227
+ const t = s.tickSize > 0n ? Number((price - s.floor) / s.tickSize) : 0;
228
+ return Math.min(t, s.numTicks - 1);
229
+ }
230
+ static priceAt(s, tick) {
231
+ return s.floor + BigInt(tick) * s.tickSize;
232
+ }
233
+ /** Whether a wallet is on a drop's WL or GTD list (and with how many GTD spots). */
234
+ async listed(drop, wallet) {
235
+ const d = await this.details(drop);
236
+ const me = wallet.toLowerCase();
237
+ const gtd = d.gtd?.find((e) => e.wallet.toLowerCase() === me);
238
+ const gtdMinted = gtd ? Number(await this.publicClient.readContract({ address: drop, abi: cloakDropAbi, functionName: "gtdMintedBy", args: [wallet] })) : 0;
239
+ return { wl: !!d.wl?.some((a) => a.toLowerCase() === me), gtdSpots: gtd?.spots ?? 0, gtdMinted };
240
+ }
241
+ /** Mint GTD spots from the listed wallet (the connected one). */
242
+ async gtdMint(drop, quantity) {
243
+ const w = this.wallet();
244
+ const [s, d] = await Promise.all([this.get(drop), this.details(drop)]);
245
+ const me = w.account.address;
246
+ const entry = d.gtd?.find((e) => e.wallet.toLowerCase() === me.toLowerCase());
247
+ if (!entry)
248
+ throw new Error("This wallet isn't on the GTD list.");
249
+ const tree = merkleTree((d.gtd ?? []).map((e) => gtdLeaf(getAddress(e.wallet), e.spots)));
250
+ const leaf = gtdLeaf(getAddress(entry.wallet), entry.spots);
251
+ const hash = await w.writeContract({ address: drop, abi: cloakDropAbi, functionName: "gtdMint", args: [entry.spots, tree.proof(leaf), quantity], value: s.gtdPrice * BigInt(quantity) });
252
+ await this.send(hash);
253
+ return hash;
254
+ }
255
+ /**
256
+ * Place a sealed bid: `quantity` NFTs at up to `price` wei each. The bid is encrypted to the drop's drand round, so
257
+ * nobody can read it before bidding closes; the keeper opens it then. A WL bid must come from the listed wallet.
258
+ * `deposit` may be more than the bid needs (the rest comes back), which hides the bid's size from the deposit.
259
+ */
260
+ async bid(drop, o) {
261
+ const w = this.wallet();
262
+ const s = await this.get(drop);
263
+ if (s.phase !== "bidding")
264
+ throw new Error(s.phase === "gtd" ? "Bidding opens when the GTD window closes." : "Bidding has closed.");
265
+ if (!Number.isInteger(o.quantity) || o.quantity < 1 || o.quantity > s.maxPerBid)
266
+ throw new Error(`Bid for 1 to ${s.maxPerBid} NFTs.`);
267
+ const tick = CloakDrops.tickFor(s, o.price);
268
+ const cost = CloakDrops.priceAt(s, tick) * BigInt(o.quantity);
269
+ const deposit = o.deposit ?? cost;
270
+ if (deposit < cost)
271
+ throw new Error("The deposit must cover the bid.");
272
+ let proof = [];
273
+ if (o.wl) {
274
+ const d = await this.details(drop);
275
+ const list = (d.wl ?? []).map((a) => getAddress(a));
276
+ if (!list.some((a) => a.toLowerCase() === w.account.address.toLowerCase()))
277
+ throw new Error("This wallet isn't on the WL.");
278
+ proof = merkleTree(list.map(wlLeaf)).proof(wlLeaf(w.account.address));
279
+ }
280
+ const salt = toHex(crypto.getRandomValues(new Uint8Array(32)));
281
+ const quantity = BigInt(o.quantity);
282
+ const commitment = commitmentOf(drop, w.account.address, tick, quantity, salt);
283
+ const sealed = await sealBid({ auction: drop, bidder: w.account.address, tick, quantity, salt }, s.tlockRound);
284
+ const { result, request } = await this.publicClient.simulateContract({ address: drop, abi: cloakDropAbi, functionName: "commit", args: [commitment, sealed, !!o.wl, proof], value: deposit, account: w.account });
285
+ const txHash = await w.writeContract(request);
286
+ await this.send(txHash);
287
+ return { id: result, txHash, tick, salt };
288
+ }
289
+ /** Read logs from `fromBlock` to now in one call, or in 100k-block pieces where the RPC limits ranges. */
290
+ async chunked(get, firstOnly = false) {
291
+ const latest = await this.publicClient.getBlockNumber({ cacheTime: 0 });
292
+ try {
293
+ return await get(this.fromBlock, latest);
294
+ }
295
+ catch {
296
+ const out = [];
297
+ for (let from = this.fromBlock; from <= latest; from += 100000n) {
298
+ out.push(...(await get(from, from + 99999n > latest ? latest : from + 99999n)));
299
+ if (firstOnly && out.length)
300
+ break;
301
+ }
302
+ return out;
303
+ }
304
+ }
305
+ async committedLogs(drop) {
306
+ return this.chunked((from, to) => this.publicClient.getContractEvents({ address: drop, abi: cloakDropAbi, eventName: "Committed", strict: true, fromBlock: from, toBlock: to }));
307
+ }
308
+ /** Deliver one bid now: its NFTs to the bidder and the rest of its deposit back (anyone can, it's always the bidder's). */
309
+ async claim(drop, id) {
310
+ const { request } = await this.publicClient.simulateContract({ address: drop, abi: cloakDropAbi, functionName: "claim", args: [id], account: this.wallet().account });
311
+ const h = await this.wallet().writeContract(request);
312
+ await this.send(h);
313
+ return h;
314
+ }
315
+ /** A refund the drop couldn't send (a wallet that refuses HYPE) waits here for its bidder. */
316
+ async refundOwed(drop, bidder) {
317
+ return this.publicClient.readContract({ address: drop, abi: cloakDropAbi, functionName: "refundOwed", args: [bidder] });
318
+ }
319
+ async withdrawRefund(drop, to) {
320
+ const { request } = await this.publicClient.simulateContract({ address: drop, abi: cloakDropAbi, functionName: "withdrawRefund", args: [to], account: this.wallet().account });
321
+ const h = await this.wallet().writeContract(request);
322
+ await this.send(h);
323
+ return h;
324
+ }
325
+ /** Every bid on a drop (or one bidder's), with what it won once settled. */
326
+ async bids(drop, bidder) {
327
+ const [s, n] = await Promise.all([this.get(drop), this.publicClient.readContract({ address: drop, abi: cloakDropAbi, functionName: "bidsCount" })]);
328
+ if (!n)
329
+ return [];
330
+ const ids = Array.from({ length: Number(n) }, (_, i) => BigInt(i));
331
+ const raw = (await this.reads(ids.map((id) => ({ address: drop, abi: cloakDropAbi, functionName: "getBid", args: [id] }))));
332
+ const ready = s.settled && (s.tieSupply === 0 || s.phase === "settled");
333
+ const alloc = ready ? await this.reads(ids.map((id) => ({ address: drop, abi: cloakDropAbi, functionName: "allocationOf", args: [id] }))) : [];
334
+ return raw
335
+ .map((b, i) => {
336
+ const a = alloc[i];
337
+ return { id: ids[i], bidder: b.bidder, wl: b.wl, revealed: b.revealed, claimed: b.claimed, tick: b.tick, price: CloakDrops.priceAt(s, b.tick), quantity: b.quantity, deposit: b.deposit, nfts: a ? Number(a[0]) : 0, paid: a?.[1] ?? 0n, refund: a?.[2] ?? 0n };
338
+ })
339
+ .filter((b) => !bidder || b.bidder.toLowerCase() === bidder.toLowerCase());
340
+ }
341
+ // ── what the keeper (or anyone) runs ──
342
+ /** Open every sealed bid whose drand round has passed. Garbage ciphertexts are skipped (and penalised at claim). */
343
+ async revealAll(drop, opts = {}) {
344
+ const log = opts.log ?? (() => { });
345
+ const logs = await this.committedLogs(drop);
346
+ const reveals = [];
347
+ for (const l of logs) {
348
+ const b = await this.publicClient.readContract({ address: drop, abi: cloakDropAbi, functionName: "getBid", args: [l.args.id] });
349
+ if (b.revealed)
350
+ continue;
351
+ try {
352
+ const p = await unsealBid(l.args.sealedBid);
353
+ if (p.auction.toLowerCase() !== drop.toLowerCase())
354
+ throw new Error("sealed for another drop");
355
+ if (commitmentOf(drop, b.bidder, p.tick, p.quantity, p.salt) !== b.commitment)
356
+ throw new Error("ciphertext does not match commitment");
357
+ reveals.push({ id: l.args.id, tick: p.tick, quantity: p.quantity, salt: p.salt });
358
+ }
359
+ catch (e) {
360
+ log(`bid #${l.args.id}: cannot unseal (${e.message})`);
361
+ }
362
+ }
363
+ const size = opts.batchSize ?? 40;
364
+ for (let i = 0; i < reveals.length; i += size) {
365
+ await this.send(await this.wallet().writeContract({ address: drop, abi: cloakDropAbi, functionName: "revealBatch", args: [reveals.slice(i, i + size)] }));
366
+ log(`revealed ${Math.min(i + size, reveals.length)}/${reveals.length}`);
367
+ }
368
+ return { revealed: reveals.length };
369
+ }
370
+ /** Settle, then number the tied bids (if any) so claims can start. */
371
+ async settle(drop, opts = {}) {
372
+ const c = { address: drop, abi: cloakDropAbi };
373
+ const txs = [];
374
+ if (!(await this.publicClient.readContract({ ...c, functionName: "settled" }))) {
375
+ const h = await this.wallet().writeContract({ ...c, functionName: "settle" });
376
+ await this.send(h);
377
+ txs.push(h);
378
+ }
379
+ while ((await this.get(drop)).phase === "numbering") {
380
+ const h = await this.wallet().writeContract({ ...c, functionName: "numberTies", args: [BigInt(opts.tieBatch ?? 400)] });
381
+ await this.send(h);
382
+ txs.push(h);
383
+ }
384
+ return txs;
385
+ }
386
+ /** Deliver every unclaimed bid (NFTs and refunds always go to the bidder, never the caller). */
387
+ async claimAll(drop, opts = {}) {
388
+ const ids = (await this.bids(drop)).filter((b) => !b.claimed).map((b) => b.id);
389
+ const size = opts.batchSize ?? 25;
390
+ for (let i = 0; i < ids.length; i += size)
391
+ await this.send(await this.wallet().writeContract({ address: drop, abi: cloakDropAbi, functionName: "claimBatch", args: [ids.slice(i, i + size)] }));
392
+ return { claimed: ids.length };
393
+ }
394
+ /** The bidder takes its NFTs to another address (a fresh wallet, say); the refund still goes to the bidder. */
395
+ async claimTo(drop, id, to) {
396
+ const { request } = await this.publicClient.simulateContract({ address: drop, abi: cloakDropAbi, functionName: "claimTo", args: [id, to], account: this.wallet().account });
397
+ const h = await this.wallet().writeContract(request);
398
+ await this.send(h);
399
+ return h;
400
+ }
401
+ /** Pay out the creator's proceeds and the protocol fee (anyone can call; the destinations are fixed). */
402
+ async payOut(drop) {
403
+ const c = { address: drop, abi: cloakDropAbi };
404
+ const [owed, fees] = await Promise.all([this.publicClient.readContract({ ...c, functionName: "creatorOwed" }), this.publicClient.readContract({ ...c, functionName: "feesOwed" })]);
405
+ const txs = [];
406
+ if (owed > 0n) {
407
+ const h = await this.wallet().writeContract({ ...c, functionName: "withdrawProceeds" });
408
+ await this.send(h);
409
+ txs.push(h);
410
+ }
411
+ if (fees > 0n) {
412
+ try {
413
+ await this.publicClient.simulateContract({ ...c, functionName: "collectFees", account: this.wallet().account });
414
+ const h = await this.wallet().writeContract({ ...c, functionName: "collectFees" });
415
+ await this.send(h);
416
+ txs.push(h);
417
+ }
418
+ catch { }
419
+ }
420
+ return txs;
421
+ }
422
+ /** The creator mints what nobody bought (or all of it, if the drop failed). */
423
+ async mintUnsold(drop, to, quantity) {
424
+ const h = await this.wallet().writeContract({ address: drop, abi: cloakDropAbi, functionName: "mintUnsold", args: [to, quantity] });
425
+ await this.send(h);
426
+ return h;
427
+ }
428
+ }
package/dist/index.d.ts CHANGED
@@ -4,4 +4,6 @@ export * from "./deployments.js";
4
4
  export * from "./registry.js";
5
5
  export * from "./seal.js";
6
6
  export * from "./cloak.js";
7
+ export * from "./drops.js";
7
8
  export * from "./private/index.js";
9
+ export * from "./spots.js";
package/dist/index.js CHANGED
@@ -4,4 +4,6 @@ export * from "./deployments.js";
4
4
  export * from "./registry.js";
5
5
  export * from "./seal.js";
6
6
  export * from "./cloak.js";
7
+ export * from "./drops.js";
7
8
  export * from "./private/index.js";
9
+ export * from "./spots.js";
@@ -104,6 +104,8 @@ export declare class CloakPrivate {
104
104
  private state;
105
105
  /** Pull new leaves from the pool's events, trial-decrypt them, and refresh spent flags. */
106
106
  sync(pool: Address): Promise<OwnedNote[]>;
107
+ /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
108
+ history(pool: Address): Promise<OwnedNote[]>;
107
109
  balance(pool: Address): Promise<bigint>;
108
110
  /** The most a single relayed call of this kind can move from `pool`: the balance minus the relayer's fee. */
109
111
  maxSpendable(pool: Address, kind?: RelayKind): Promise<bigint>;
@@ -249,6 +249,11 @@ export class CloakPrivate {
249
249
  s.notes.forEach((x, i) => (x.spent = Boolean(flags[i])));
250
250
  return s.notes.filter((x) => !x.spent);
251
251
  }
252
+ /** Every note this account has received in `pool`, spent ones included (each with `spent` and its nullifier). */
253
+ async history(pool) {
254
+ await this.sync(pool);
255
+ return [...this.state(pool).notes];
256
+ }
252
257
  async balance(pool) {
253
258
  return (await this.sync(pool)).reduce((a, x) => a + x.amount, 0n);
254
259
  }
@@ -22,6 +22,18 @@ export declare const newLinkSecret: () => Uint8Array;
22
22
  export declare function linkKeys(secret: Uint8Array, passcode?: string): Promise<CloakKeys>;
23
23
  /** The cloak: address a link's money is sent to. */
24
24
  export declare function linkAddress(keys: CloakKeys): Promise<string>;
25
+ /** The 8-hex-digit check a locked link carries: the first 4 bytes of a hash of the account the passcode opens. */
26
+ export declare function linkCheck(keys: CloakKeys): Promise<string>;
27
+ /** Thrown when a link's check says the passcode is wrong. */
28
+ export declare class WrongPasscodeError extends Error {
29
+ constructor(riddle: boolean);
30
+ }
31
+ /**
32
+ * Opens a link's account. A locked link with a check tries the code (and the ways a phone may have mangled it) and
33
+ * throws WrongPasscodeError if none matches, without touching the network. A link without a check (made before
34
+ * checks existed) opens with the code as typed; an empty account is then all a wrong code looks like.
35
+ */
36
+ export declare function openLinkKeys(l: ClaimLink, passcode?: string): Promise<CloakKeys>;
25
37
  export interface ClaimLink {
26
38
  secret: Uint8Array;
27
39
  /** Block the link was funded in. Shown to the claimer; scanning still starts at the vaults' first block, because
@@ -40,7 +52,15 @@ export interface ClaimLink {
40
52
  hint?: string;
41
53
  /** Split into this many equal shares: each person who opens the link claims one (a red envelope). */
42
54
  shares?: number;
55
+ /** A short check of the account a passcode opens, so a wrong passcode is caught on the device, before any
56
+ * network call. It tells a guesser nothing a copy of the link didn't already: each guess still costs a full scrypt. */
57
+ check?: string;
43
58
  }
59
+ /**
60
+ * A claim link: https://cloak.boblabs.tech/claim#c<letters and digits>. Everything after the # is one run of letters
61
+ * and digits (base 62), because chat apps end a link at characters like "-", "_", "." or "%" (WhatsApp and Telegram
62
+ * both cut links that way) and a cut link loses the money's key.
63
+ */
44
64
  export declare function buildClaimLink(l: ClaimLink, origin?: string): string;
45
65
  /** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
46
66
  export declare function parseClaimLink(link: string): ClaimLink;
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { scryptAsync } from "@noble/hashes/scrypt";
23
23
  import { randomBytes } from "@noble/hashes/utils";
24
- import { concat, isAddress, keccak256, toBytes } from "viem";
24
+ import { concat, isAddress, keccak256, toBytes, toHex } from "viem";
25
25
  import { CloakKeys, formatCloakAddress, parseCloakAddress } from "./crypto.js";
26
26
  export const LINK_ORIGIN = "https://cloak.boblabs.tech";
27
27
  export const LINK_MESSAGE_MAX = 140;
@@ -64,9 +64,46 @@ export async function linkKeys(secret, passcode) {
64
64
  export async function linkAddress(keys) {
65
65
  return formatCloakAddress((await keys.main()).pubKey, keys.encPub);
66
66
  }
67
- export function buildClaimLink(l, origin = LINK_ORIGIN) {
67
+ /** The 8-hex-digit check a locked link carries: the first 4 bytes of a hash of the account the passcode opens. */
68
+ export async function linkCheck(keys) {
69
+ return keccak256(concat([toBytes("cloak-link-check-v1"), toBytes(await linkAddress(keys))])).slice(2, 10);
70
+ }
71
+ /** Thrown when a link's check says the passcode is wrong. */
72
+ export class WrongPasscodeError extends Error {
73
+ constructor(riddle) {
74
+ super(riddle ? "That's not the answer. Check the spelling and try again." : "That's not the passcode. Check it with the sender and try again.");
75
+ this.name = "WrongPasscodeError";
76
+ }
77
+ }
78
+ /** Ways a phone may have typed the same code: curly quotes and long dashes from autocorrect, a trailing full stop. */
79
+ function passcodeVariants(code) {
80
+ const base = normalizePasscode(code);
81
+ const straight = base.replace(/[\u2018\u2019\u201b\u2032]/g, "'").replace(/[\u201c\u201d\u2033]/g, '"').replace(/[\u2013\u2014\u2212]/g, "-");
82
+ return [...new Set([base, straight, straight.replace(/[.!?]+$/, "")])].filter(Boolean);
83
+ }
84
+ /**
85
+ * Opens a link's account. A locked link with a check tries the code (and the ways a phone may have mangled it) and
86
+ * throws WrongPasscodeError if none matches, without touching the network. A link without a check (made before
87
+ * checks existed) opens with the code as typed; an empty account is then all a wrong code looks like.
88
+ */
89
+ export async function openLinkKeys(l, passcode) {
90
+ if (!l.locked)
91
+ return linkKeys(l.secret);
92
+ if (!passcode || !normalizePasscode(passcode))
93
+ throw new Error(l.hint ? "Type the answer." : "Enter the passcode.");
94
+ if (!l.check)
95
+ return linkKeys(l.secret, passcode);
96
+ for (const v of passcodeVariants(passcode)) {
97
+ const k = await linkKeys(l.secret, v);
98
+ if ((await linkCheck(k)) === l.check)
99
+ return k;
100
+ }
101
+ throw new WrongPasscodeError(!!l.hint);
102
+ }
103
+ /** The fields of a claim link as query parameters (the secret in hex). */
104
+ function claimParams(l) {
68
105
  const p = new URLSearchParams();
69
- p.set("k", b64url(l.secret));
106
+ p.set("k", toHex(l.secret).slice(2));
70
107
  p.set("b", l.block.toString());
71
108
  if (l.token)
72
109
  p.set("t", l.token);
@@ -85,24 +122,69 @@ export function buildClaimLink(l, origin = LINK_ORIGIN) {
85
122
  p.set("q", q);
86
123
  if (l.shares && l.shares > 1)
87
124
  p.set("n", String(Math.min(Math.floor(l.shares), LINK_SHARES_MAX)));
88
- return `${origin.replace(/\/+$/, "")}/claim#${p.toString()}`;
125
+ if (l.check && /^[0-9a-f]{8}$/.test(l.check))
126
+ p.set("c", l.check);
127
+ return p;
128
+ }
129
+ /**
130
+ * A claim link: https://cloak.boblabs.tech/claim#c<letters and digits>. Everything after the # is one run of letters
131
+ * and digits (base 62), because chat apps end a link at characters like "-", "_", "." or "%" (WhatsApp and Telegram
132
+ * both cut links that way) and a cut link loses the money's key.
133
+ */
134
+ export function buildClaimLink(l, origin = LINK_ORIGIN) {
135
+ return `${origin.replace(/\/+$/, "")}/claim#c${toBase62(new TextEncoder().encode(claimParams(l).toString()))}`;
136
+ }
137
+ const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
138
+ function toBase62(bytes) {
139
+ // a leading 1 byte keeps leading zero bytes; the payload is a few hundred bytes, so plain bigint math is fine
140
+ let n = BigInt(toHex(concat([new Uint8Array([1]), bytes])));
141
+ let out = "";
142
+ while (n > 0n) {
143
+ out = B62[Number(n % 62n)] + out;
144
+ n /= 62n;
145
+ }
146
+ return out;
147
+ }
148
+ function fromBase62(s) {
149
+ let n = 0n;
150
+ for (const ch of s) {
151
+ const v = B62.indexOf(ch);
152
+ if (v < 0)
153
+ throw new Error("bad link");
154
+ n = n * 62n + BigInt(v);
155
+ }
156
+ let hex = n.toString(16);
157
+ if (hex.length % 2)
158
+ hex = "0" + hex;
159
+ const b = toBytes(`0x${hex}`);
160
+ if (b[0] !== 1)
161
+ throw new Error("bad link");
162
+ return b.slice(1);
89
163
  }
90
164
  /** Reads a claim link (the whole URL or just its fragment). Throws on anything that isn't one. */
91
165
  export function parseClaimLink(link) {
92
- const frag = link.includes("#") ? link.slice(link.indexOf("#") + 1) : link;
166
+ let frag = (link.includes("#") ? link.slice(link.indexOf("#") + 1) : link).trim();
167
+ if (/^c[0-9A-Za-z]+$/.test(frag)) {
168
+ try {
169
+ frag = new TextDecoder("utf-8", { fatal: true }).decode(fromBase62(frag.slice(1)));
170
+ }
171
+ catch {
172
+ throw new Error("This claim link is cut short or damaged. Ask the sender to copy the whole link again.");
173
+ }
174
+ }
93
175
  const p = new URLSearchParams(frag);
94
176
  const k = p.get("k");
95
177
  if (!k)
96
178
  throw new Error("This isn't a Cloak claim link.");
97
179
  let secret;
98
180
  try {
99
- secret = unb64url(k);
181
+ secret = /^[0-9a-fA-F]{64}$/.test(k) ? toBytes(`0x${k}`) : unb64url(k);
100
182
  }
101
183
  catch {
102
184
  throw new Error("This claim link is damaged. Ask the sender to copy it again.");
103
185
  }
104
186
  if (secret.length !== 32)
105
- throw new Error("This claim link is cut short. Ask the sender to copy it again.");
187
+ throw new Error("This claim link is cut short. Ask the sender to copy the whole link again.");
106
188
  const b = p.get("b") ?? "0";
107
189
  const t = p.get("t");
108
190
  const a = p.get("a");
@@ -116,6 +198,7 @@ export function parseClaimLink(link) {
116
198
  locked: p.get("p") === "1",
117
199
  hint: cleanLinkText(p.get("q"), LINK_HINT_MAX) || undefined,
118
200
  shares: /^\d{1,2}$/.test(p.get("n") ?? "") && Number(p.get("n")) > 1 ? Math.min(Number(p.get("n")), LINK_SHARES_MAX) : undefined,
201
+ check: /^[0-9a-f]{8}$/.test(p.get("c") ?? "") ? p.get("c") : undefined,
119
202
  };
120
203
  }
121
204
  /** Checks an amount and returns it as a plain decimal, or throws a plain-language error. Empty means "payer chooses". */