@interest-protocol/blast-migrators-sdk 0.0.0-stage → 0.1.0

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/src/reducer.ts ADDED
@@ -0,0 +1,562 @@
1
+ import type { NativeTransactionPosition } from "@interest-protocol/blast-core/events";
2
+ import { normalizeNativeTransactionPosition } from "@interest-protocol/blast-core/events";
3
+ import { requireU64 } from "@interest-protocol/blast-core/integers";
4
+ import type { FeeSplit } from "@interest-protocol/blast-launchpad-sdk/allocation";
5
+ import type { DecodedLaunchpadEvent } from "@interest-protocol/blast-launchpad-sdk/events";
6
+ import type { LaunchpadEventProjection } from "@interest-protocol/blast-launchpad-sdk/reducer";
7
+ import {
8
+ createLaunchpadEventProjection,
9
+ reduceLaunchpadEvent,
10
+ } from "@interest-protocol/blast-launchpad-sdk/reducer";
11
+ import {
12
+ normalizeSuiAddress,
13
+ normalizeSuiObjectId,
14
+ toHex,
15
+ } from "@mysten/sui/utils";
16
+ import type { DecodedMigratorEvent, MigratorEventKind } from "./events.ts";
17
+ import { migratorEventId, migratorEventSpecs } from "./events.ts";
18
+
19
+ export type LpVaultEventProjection = {
20
+ vaultId: string;
21
+ /** The launch this vault completed, linked by Meme type in the product projection. */
22
+ launchId: string | null;
23
+ poolId: string;
24
+ positionId: string;
25
+ /** Separate full-range position Cetus auto-LP adds to; null until first use. */
26
+ autoLpPositionId: string | null;
27
+ venueType: string;
28
+ memeType: string;
29
+ quoteType: string;
30
+ feeSplit: FeeSplit;
31
+ creatorMeme: bigint;
32
+ creatorQuote: bigint;
33
+ autoLpMeme: bigint;
34
+ autoLpQuote: bigint;
35
+ rewardsMeme: bigint;
36
+ rewardsQuote: bigint;
37
+ /** Burn-share Quote waiting for a buyback. */
38
+ burnQuote: bigint;
39
+ /** Protocol migration residuals the venue returned. */
40
+ protocolMemeResidual: bigint;
41
+ protocolQuoteResidual: bigint;
42
+ /** Liquidity of the migration position. */
43
+ liquidity: bigint;
44
+ nextRound: bigint;
45
+ lastCheckpoint: bigint | null;
46
+ /** Cumulative protocol LP-fee payouts. */
47
+ protocolMemePaid: bigint;
48
+ protocolQuotePaid: bigint;
49
+ /** Blast's LP fees a partnered vault holds for its next partner claim. */
50
+ protocolMemeHeld: bigint;
51
+ protocolQuoteHeld: bigint;
52
+ /** Cumulative cut of Blast's LP fees paid to the launch's partner. */
53
+ partnerMemePaid: bigint;
54
+ partnerQuotePaid: bigint;
55
+ };
56
+
57
+ export type HolderRewardRoundProjection = {
58
+ roundId: string;
59
+ vaultId: string;
60
+ number: bigint;
61
+ startCheckpoint: bigint;
62
+ endCheckpoint: bigint;
63
+ root: string;
64
+ manifestDigest: string;
65
+ leafCount: bigint;
66
+ memeRemaining: bigint;
67
+ quoteRemaining: bigint;
68
+ paidIndexes: Readonly<Record<string, true>>;
69
+ };
70
+
71
+ export type LpFeePolicyProjection = {
72
+ /** Default Meme/Quote shares, once a change is replayed; initialization sets zero/zero. */
73
+ defaultShares: { memeBps: bigint; quoteBps: bigint };
74
+ /** Original Meme type keyed pairs that override the defaults. */
75
+ coinShares: Readonly<Record<string, { memeBps: bigint; quoteBps: bigint }>>;
76
+ /** Protocol destination once redirected; initialization pays the publisher. */
77
+ protocolDestination: string | null;
78
+ };
79
+
80
+ export type CetusPoolReservationProjection = {
81
+ memeType: string;
82
+ quoteType: string;
83
+ poolCreationCapId: string;
84
+ tickSpacing: number;
85
+ };
86
+
87
+ export type MigratorEventProjection = {
88
+ appliedEventIds: Readonly<Record<string, true>>;
89
+ eventCounts: Readonly<Record<MigratorEventKind, number>>;
90
+ vaults: Readonly<Record<string, LpVaultEventProjection>>;
91
+ rounds: Readonly<Record<string, HolderRewardRoundProjection>>;
92
+ lpFeePolicy: LpFeePolicyProjection;
93
+ /** Cetus pool-creation reservations, keyed by Meme type. */
94
+ cetusPoolReservations: Readonly<
95
+ Record<string, CetusPoolReservationProjection>
96
+ >;
97
+ };
98
+
99
+ export type { NativeTransactionPosition } from "@interest-protocol/blast-core/events";
100
+
101
+ export type PackagePublicationFact = {
102
+ source: "transactionEffects";
103
+ kind: "packagePublication";
104
+ position: NativeTransactionPosition;
105
+ packageId: string;
106
+ createdObjectIds: readonly string[];
107
+ };
108
+
109
+ export type CreatorCapOwnershipFact = {
110
+ source: "objectOwnership";
111
+ kind: "creatorCapOwnership";
112
+ checkpoint: bigint;
113
+ creatorCapId: string;
114
+ owner: string;
115
+ };
116
+
117
+ /** A coin a claim returned and where its transaction sent it. */
118
+ export type ReturnedCoin = { objectId: string; recipient: string };
119
+
120
+ /** The coins one claim returned: a launch protocol claim also returns Blast's SUI. */
121
+ export type ReturnedCoinRecipientsFact = {
122
+ source: "transactionEffects";
123
+ kind: "returnedCoinRecipients";
124
+ position: NativeTransactionPosition;
125
+ memeCoin: ReturnedCoin;
126
+ quoteCoin: ReturnedCoin;
127
+ } & (
128
+ | { claim: "launchCreatorFees" | "lpCreatorFees" | "lpProtocolFees" }
129
+ | { claim: "launchProtocolFees"; suiCoin: ReturnedCoin }
130
+ );
131
+
132
+ export type NativeReconstructionFact =
133
+ | PackagePublicationFact
134
+ | CreatorCapOwnershipFact
135
+ | ReturnedCoinRecipientsFact;
136
+
137
+ export type NativeFactProjection = {
138
+ appliedFactIds: Readonly<Record<string, true>>;
139
+ packagePublications: Readonly<Record<string, PackagePublicationFact>>;
140
+ creatorCapOwners: Readonly<
141
+ Record<
142
+ string,
143
+ { source: "objectOwnership"; checkpoint: bigint; owner: string }
144
+ >
145
+ >;
146
+ returnedCoinRecipients: Readonly<Record<string, ReturnedCoinRecipientsFact>>;
147
+ };
148
+
149
+ export type LaunchpadProductProjection = {
150
+ launchpad: LaunchpadEventProjection;
151
+ migrators: MigratorEventProjection;
152
+ native: NativeFactProjection;
153
+ };
154
+
155
+ export type LaunchpadProductInput =
156
+ | DecodedLaunchpadEvent
157
+ | DecodedMigratorEvent
158
+ | NativeReconstructionFact;
159
+
160
+ export function createMigratorEventProjection(): MigratorEventProjection {
161
+ return {
162
+ appliedEventIds: {},
163
+ eventCounts: Object.fromEntries(
164
+ Object.keys(migratorEventSpecs).map((kind) => [kind, 0]),
165
+ ) as Record<MigratorEventKind, number>,
166
+ vaults: {},
167
+ rounds: {},
168
+ lpFeePolicy: {
169
+ defaultShares: { memeBps: 0n, quoteBps: 0n },
170
+ coinShares: {},
171
+ protocolDestination: null,
172
+ },
173
+ cetusPoolReservations: {},
174
+ };
175
+ }
176
+
177
+ export function createLaunchpadProductProjection(): LaunchpadProductProjection {
178
+ return {
179
+ launchpad: createLaunchpadEventProjection(),
180
+ migrators: createMigratorEventProjection(),
181
+ native: {
182
+ appliedFactIds: {},
183
+ packagePublications: {},
184
+ creatorCapOwners: {},
185
+ returnedCoinRecipients: {},
186
+ },
187
+ };
188
+ }
189
+
190
+ export function reduceMigratorEvent(
191
+ state: MigratorEventProjection,
192
+ event: DecodedMigratorEvent,
193
+ ): MigratorEventProjection {
194
+ const eventId = migratorEventId(event);
195
+ if (state.appliedEventIds[eventId]) return state;
196
+ const next: MigratorEventProjection = {
197
+ ...state,
198
+ appliedEventIds: { ...state.appliedEventIds, [eventId]: true },
199
+ eventCounts: {
200
+ ...state.eventCounts,
201
+ [event.kind]: state.eventCounts[event.kind] + 1,
202
+ },
203
+ };
204
+
205
+ switch (event.kind) {
206
+ case "VaultCreated": {
207
+ const payload = event.payload;
208
+ return {
209
+ ...next,
210
+ vaults: {
211
+ ...state.vaults,
212
+ [payload.vault_id]: {
213
+ vaultId: payload.vault_id,
214
+ launchId: null,
215
+ poolId: payload.pool_id,
216
+ positionId: payload.position_id,
217
+ autoLpPositionId: null,
218
+ venueType: payload.venue,
219
+ memeType: payload.meme,
220
+ quoteType: payload.quote,
221
+ feeSplit: payload.fee_split,
222
+ creatorMeme: 0n,
223
+ creatorQuote: 0n,
224
+ autoLpMeme: 0n,
225
+ autoLpQuote: 0n,
226
+ rewardsMeme: 0n,
227
+ rewardsQuote: 0n,
228
+ burnQuote: 0n,
229
+ protocolMemeResidual: payload.residual_meme,
230
+ protocolQuoteResidual: payload.residual_quote,
231
+ liquidity: payload.liquidity,
232
+ nextRound: 0n,
233
+ lastCheckpoint: null,
234
+ protocolMemePaid: 0n,
235
+ protocolQuotePaid: 0n,
236
+ protocolMemeHeld: 0n,
237
+ protocolQuoteHeld: 0n,
238
+ partnerMemePaid: 0n,
239
+ partnerQuotePaid: 0n,
240
+ },
241
+ },
242
+ };
243
+ }
244
+ case "FeesCollected": {
245
+ const payload = event.payload;
246
+ return updateVault(next, payload.vault_id, (vault) => ({
247
+ ...vault,
248
+ creatorMeme: vault.creatorMeme + payload.creator_meme,
249
+ creatorQuote: vault.creatorQuote + payload.creator_quote,
250
+ autoLpMeme: vault.autoLpMeme + payload.auto_lp_meme,
251
+ autoLpQuote: vault.autoLpQuote + payload.auto_lp_quote,
252
+ rewardsMeme: vault.rewardsMeme + payload.rewards_meme,
253
+ rewardsQuote: vault.rewardsQuote + payload.rewards_quote,
254
+ burnQuote: vault.burnQuote + payload.burn_quote,
255
+ // A partnered vault holds Blast's share: no destination was paid.
256
+ ...(payload.protocol_destination === null
257
+ ? {
258
+ protocolMemeHeld: vault.protocolMemeHeld + payload.protocol_meme,
259
+ protocolQuoteHeld:
260
+ vault.protocolQuoteHeld + payload.protocol_quote,
261
+ }
262
+ : {
263
+ protocolMemePaid: vault.protocolMemePaid + payload.protocol_meme,
264
+ protocolQuotePaid:
265
+ vault.protocolQuotePaid + payload.protocol_quote,
266
+ }),
267
+ }));
268
+ }
269
+ case "ProtocolLpFeesClaimed": {
270
+ const payload = event.payload;
271
+ return updateVault(next, payload.vault_id, (vault) => ({
272
+ ...vault,
273
+ protocolMemeHeld: 0n,
274
+ protocolQuoteHeld: 0n,
275
+ protocolMemePaid: vault.protocolMemePaid + payload.protocol_meme,
276
+ protocolQuotePaid: vault.protocolQuotePaid + payload.protocol_quote,
277
+ partnerMemePaid: vault.partnerMemePaid + payload.partner_meme,
278
+ partnerQuotePaid: vault.partnerQuotePaid + payload.partner_quote,
279
+ }));
280
+ }
281
+ case "AutoLiquidityAdded": {
282
+ const payload = event.payload;
283
+ return updateVault(next, payload.vault_id, (vault) => ({
284
+ ...vault,
285
+ autoLpMeme: payload.meme_pending,
286
+ autoLpQuote: payload.quote_pending,
287
+ }));
288
+ }
289
+ case "BuybackBurned": {
290
+ const payload = event.payload;
291
+ return updateVault(next, payload.vault_id, (vault) => ({
292
+ ...vault,
293
+ burnQuote: payload.quote_pending,
294
+ }));
295
+ }
296
+ case "AutoLpPositionOpened": {
297
+ const payload = event.payload;
298
+ return updateVault(next, payload.vault_id, (vault) => ({
299
+ ...vault,
300
+ autoLpPositionId: payload.position_id,
301
+ }));
302
+ }
303
+ case "FeeSplitChanged": {
304
+ const payload = event.payload;
305
+ return updateVault(next, payload.vault_id, (vault) => ({
306
+ ...vault,
307
+ feeSplit: payload.new_fee_split,
308
+ }));
309
+ }
310
+ case "CreatorFeesClaimed":
311
+ return updateVault(next, event.payload.vault_id, (vault) => ({
312
+ ...vault,
313
+ creatorMeme: 0n,
314
+ creatorQuote: 0n,
315
+ }));
316
+ case "ProtocolFeesClaimed":
317
+ return updateVault(next, event.payload.vault_id, (vault) => ({
318
+ ...vault,
319
+ protocolMemeResidual: 0n,
320
+ protocolQuoteResidual: 0n,
321
+ }));
322
+ case "RoundPublished": {
323
+ const payload = event.payload;
324
+ const updated = updateVault(next, payload.vault_id, (vault) => ({
325
+ ...vault,
326
+ rewardsMeme: payload.meme_unallocated,
327
+ rewardsQuote: payload.quote_unallocated,
328
+ nextRound: payload.number + 1n,
329
+ lastCheckpoint: payload.end_checkpoint,
330
+ }));
331
+ return {
332
+ ...updated,
333
+ rounds: {
334
+ ...state.rounds,
335
+ [payload.round_id]: {
336
+ roundId: payload.round_id,
337
+ vaultId: payload.vault_id,
338
+ number: payload.number,
339
+ startCheckpoint: payload.start_checkpoint,
340
+ endCheckpoint: payload.end_checkpoint,
341
+ root: toHex(payload.root),
342
+ manifestDigest: toHex(payload.manifest_digest),
343
+ leafCount: payload.leaf_count,
344
+ memeRemaining: payload.meme_amount,
345
+ quoteRemaining: payload.quote_amount,
346
+ paidIndexes: {},
347
+ },
348
+ },
349
+ };
350
+ }
351
+ case "RewardPaid": {
352
+ const payload = event.payload;
353
+ const round = state.rounds[payload.round_id];
354
+ if (!round) return next;
355
+ return {
356
+ ...next,
357
+ rounds: {
358
+ ...state.rounds,
359
+ [payload.round_id]: {
360
+ ...round,
361
+ memeRemaining: payload.meme_remaining,
362
+ quoteRemaining: payload.quote_remaining,
363
+ paidIndexes: {
364
+ ...round.paidIndexes,
365
+ [payload.index.toString()]: true,
366
+ },
367
+ },
368
+ },
369
+ };
370
+ }
371
+ case "LpFeePolicyChanged": {
372
+ const payload = event.payload;
373
+ const pair =
374
+ payload.new_meme_bps === null || payload.new_quote_bps === null
375
+ ? null
376
+ : { memeBps: payload.new_meme_bps, quoteBps: payload.new_quote_bps };
377
+ if (payload.meme === null) {
378
+ if (!pair) return next;
379
+ return {
380
+ ...next,
381
+ lpFeePolicy: { ...state.lpFeePolicy, defaultShares: pair },
382
+ };
383
+ }
384
+ const coinShares = { ...state.lpFeePolicy.coinShares };
385
+ if (pair) coinShares[payload.meme] = pair;
386
+ else delete coinShares[payload.meme];
387
+ return { ...next, lpFeePolicy: { ...state.lpFeePolicy, coinShares } };
388
+ }
389
+ case "LpFeeDestinationChanged":
390
+ return {
391
+ ...next,
392
+ lpFeePolicy: {
393
+ ...state.lpFeePolicy,
394
+ protocolDestination: event.payload.new_destination,
395
+ },
396
+ };
397
+ case "CetusPoolReserved": {
398
+ const payload = event.payload;
399
+ return {
400
+ ...next,
401
+ cetusPoolReservations: {
402
+ ...state.cetusPoolReservations,
403
+ [payload.meme]: {
404
+ memeType: payload.meme,
405
+ quoteType: payload.quote,
406
+ poolCreationCapId: payload.pool_creation_cap_id,
407
+ tickSpacing: payload.tick_spacing,
408
+ },
409
+ },
410
+ };
411
+ }
412
+ }
413
+ }
414
+
415
+ export function reduceLaunchpadProductInput(
416
+ state: LaunchpadProductProjection,
417
+ input: LaunchpadProductInput,
418
+ ): LaunchpadProductProjection {
419
+ if (input.source === "launchpad") {
420
+ const launchpad = reduceLaunchpadEvent(state.launchpad, input);
421
+ return launchpad === state.launchpad ? state : { ...state, launchpad };
422
+ }
423
+ if (input.source === "migrators") {
424
+ const migrators = reduceMigratorEvent(state.migrators, input);
425
+ if (migrators === state.migrators) return state;
426
+ if (input.kind !== "VaultCreated") return { ...state, migrators };
427
+ return {
428
+ ...state,
429
+ migrators: linkVaultLaunch(migrators, state.launchpad, input.payload),
430
+ };
431
+ }
432
+ const native = reduceNativeFact(state.native, input);
433
+ return native === state.native ? state : { ...state, native };
434
+ }
435
+
436
+ function reduceNativeFact(
437
+ state: NativeFactProjection,
438
+ fact: NativeReconstructionFact,
439
+ ): NativeFactProjection {
440
+ const normalized = normalizeNativeFact(fact);
441
+ const factId = nativeFactId(normalized);
442
+ if (state.appliedFactIds[factId]) return state;
443
+ const next = {
444
+ ...state,
445
+ appliedFactIds: { ...state.appliedFactIds, [factId]: true as const },
446
+ };
447
+
448
+ switch (normalized.kind) {
449
+ case "packagePublication":
450
+ return {
451
+ ...next,
452
+ packagePublications: {
453
+ ...state.packagePublications,
454
+ [normalized.packageId]: normalized,
455
+ },
456
+ };
457
+ case "creatorCapOwnership":
458
+ return {
459
+ ...next,
460
+ creatorCapOwners: {
461
+ ...state.creatorCapOwners,
462
+ [normalized.creatorCapId]: {
463
+ source: "objectOwnership",
464
+ checkpoint: normalized.checkpoint,
465
+ owner: normalized.owner,
466
+ },
467
+ },
468
+ };
469
+ case "returnedCoinRecipients":
470
+ return {
471
+ ...next,
472
+ returnedCoinRecipients: {
473
+ ...state.returnedCoinRecipients,
474
+ [factId]: normalized,
475
+ },
476
+ };
477
+ }
478
+ }
479
+
480
+ /**
481
+ * The migration transaction migrates the launch (`Migrated`) before it
482
+ * attaches the vault (`VaultCreated`). One launch exists per Meme type, so the
483
+ * launch of the vault's Meme type is the vault's launch.
484
+ */
485
+ function linkVaultLaunch(
486
+ state: MigratorEventProjection,
487
+ launchpad: LaunchpadEventProjection,
488
+ payload: Extract<DecodedMigratorEvent, { kind: "VaultCreated" }>["payload"],
489
+ ): MigratorEventProjection {
490
+ const launch = Object.values(launchpad.launches).find(
491
+ (candidate) =>
492
+ candidate.memeType === payload.meme &&
493
+ candidate.quoteType === payload.quote,
494
+ );
495
+ if (!launch) return state;
496
+ return updateVault(state, payload.vault_id, (current) => ({
497
+ ...current,
498
+ launchId: launch.launchId,
499
+ }));
500
+ }
501
+
502
+ function updateVault(
503
+ state: MigratorEventProjection,
504
+ vaultId: string,
505
+ update: (vault: LpVaultEventProjection) => LpVaultEventProjection,
506
+ ): MigratorEventProjection {
507
+ const vault = state.vaults[vaultId];
508
+ if (!vault) return state;
509
+ return { ...state, vaults: { ...state.vaults, [vaultId]: update(vault) } };
510
+ }
511
+
512
+ function normalizeNativeFact(
513
+ fact: NativeReconstructionFact,
514
+ ): NativeReconstructionFact {
515
+ if (fact.kind === "creatorCapOwnership") {
516
+ requireU64("creator-cap ownership checkpoint", fact.checkpoint);
517
+ return {
518
+ ...fact,
519
+ creatorCapId: normalizeSuiObjectId(fact.creatorCapId),
520
+ owner: normalizeSuiAddress(fact.owner),
521
+ };
522
+ }
523
+ const position = normalizeNativeTransactionPosition(
524
+ fact.position,
525
+ "native fact",
526
+ );
527
+ if (fact.kind === "packagePublication") {
528
+ return {
529
+ ...fact,
530
+ position,
531
+ packageId: normalizeSuiObjectId(fact.packageId),
532
+ createdObjectIds: fact.createdObjectIds.map((id) =>
533
+ normalizeSuiObjectId(id),
534
+ ),
535
+ };
536
+ }
537
+ const coins = {
538
+ position,
539
+ memeCoin: normalizeReturnedCoin(fact.memeCoin),
540
+ quoteCoin: normalizeReturnedCoin(fact.quoteCoin),
541
+ };
542
+ return fact.claim === "launchProtocolFees"
543
+ ? { ...fact, ...coins, suiCoin: normalizeReturnedCoin(fact.suiCoin) }
544
+ : { ...fact, ...coins };
545
+ }
546
+
547
+ function normalizeReturnedCoin(coin: ReturnedCoin): ReturnedCoin {
548
+ return {
549
+ objectId: normalizeSuiObjectId(coin.objectId),
550
+ recipient: normalizeSuiAddress(coin.recipient),
551
+ };
552
+ }
553
+
554
+ function nativeFactId(fact: NativeReconstructionFact): string {
555
+ if (fact.kind === "creatorCapOwnership") {
556
+ return `${fact.checkpoint}:ownership:${fact.creatorCapId}`;
557
+ }
558
+ if (fact.kind === "returnedCoinRecipients") {
559
+ return `${fact.position.transactionDigest}:${fact.kind}:${fact.memeCoin.objectId}:${fact.quoteCoin.objectId}`;
560
+ }
561
+ return `${fact.position.transactionDigest}:${fact.kind}:${fact.packageId}`;
562
+ }