@bordrless/shared 0.8.0 → 0.10.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/README.md +8 -3
- package/dist/api.d.ts +584 -5
- package/dist/api.js +0 -5
- package/dist/explorer.d.ts +21 -0
- package/dist/explorer.js +94 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/keeper.d.ts +139 -0
- package/dist/keeper.js +399 -0
- package/dist/policy.d.ts +207 -27
- package/dist/policy.js +419 -34
- package/dist/programs.d.ts +79 -0
- package/dist/programs.js +84 -0
- package/dist/rules.d.ts +353 -0
- package/dist/rules.js +949 -0
- package/dist/studio.d.ts +66 -2
- package/dist/studio.js +4 -2
- package/dist/workshop.d.ts +380 -0
- package/dist/workshop.js +28 -0
- package/package.json +1 -1
package/dist/policy.js
CHANGED
|
@@ -15,6 +15,14 @@ export const CURVE_BPS = 7500n;
|
|
|
15
15
|
export const LP_FEE_BPS = 30;
|
|
16
16
|
/** Protocol fee of an ordinary DEX pool (anyone's pool), in basis points of the quote side: the flat model (§3.1). */
|
|
17
17
|
export const PROTOCOL_FEE_BPS = 100;
|
|
18
|
+
/**
|
|
19
|
+
* The LP fee tiers the create-pool form offers for an ordinary pool (docs/dex.md decision 2): 0.25%
|
|
20
|
+
* (the default, for bridged pairs), 1% and 2% (memecoins). The program takes any value from 0 to 90%;
|
|
21
|
+
* three tiers keep the number of duplicate pools per pair down (§3.5).
|
|
22
|
+
*/
|
|
23
|
+
export const POOL_FEE_TIERS_BPS = [25, 100, 200];
|
|
24
|
+
export const POOL_FEE_TIER_DEFAULT_BPS = 25;
|
|
25
|
+
export const isPoolFeeTier = (bps) => POOL_FEE_TIERS_BPS.includes(bps);
|
|
18
26
|
/**
|
|
19
27
|
* Bordrless's take on a launch pool (its curve and the same pool after graduation), the share
|
|
20
28
|
* model of §3.1 (2026-10-07: "we earn 25% of their total fees, not 0.25%"): a quarter of what the
|
|
@@ -26,6 +34,8 @@ export const PROTOCOL_FEE_BPS = 100;
|
|
|
26
34
|
export const LAUNCH_PROTOCOL_SHARE_BPS = 2_500;
|
|
27
35
|
/** The largest share the DEX config may set (`MAX_PROTOCOL_SHARE_BPS`): all of the cuts. */
|
|
28
36
|
export const MAX_PROTOCOL_SHARE_BPS = 10_000;
|
|
37
|
+
/** The largest flat protocol fee the DEX config may set (`bordrless_swap::constants::MAX_PROTOCOL_FEE_BPS`): 10% of the quote side. */
|
|
38
|
+
export const MAX_PROTOCOL_FEE_BPS = 1_000;
|
|
29
39
|
/** Largest creator fee a launch may choose. */
|
|
30
40
|
export const MAX_CREATOR_FEE_BPS = 200;
|
|
31
41
|
/** The creator fee choices offered on the launch form. */
|
|
@@ -370,30 +380,37 @@ export const COMPANION_MAX_VEST_DAYS = 365;
|
|
|
370
380
|
export const LAUNCHER_SPLIT = { buybackBps: 0, holdersBps: 0, beneficiaryBps: 10_000 };
|
|
371
381
|
// ---- lottery coins (docs/games.md) -----------------------------------------------------------------
|
|
372
382
|
/**
|
|
373
|
-
* The
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
383
|
+
* The Jackpot's limits as the site offers them: the round's length (the programs take rounds from 5
|
|
384
|
+
* minutes since the five-minute rounds upgrade of 2026-10-12; the owner set the site's floor at 15
|
|
385
|
+
* minutes, which is also its default), the minimum pot, the prize's least share, a claim window's
|
|
386
|
+
* bounds (`MIN_CLAIM_WINDOW` in the companion, a minute), the most attempts, and the half of a round
|
|
387
|
+
* the attempts may take. The launch form holds its choices to them and the backend refuses anything
|
|
388
|
+
* outside them (`lotteryProblem`). `GAME_LIMITS` in the SDK is the programs' own.
|
|
377
389
|
*/
|
|
378
390
|
export const LOTTERY_LIMITS = {
|
|
379
|
-
minRoundSecs:
|
|
391
|
+
minRoundSecs: 900,
|
|
380
392
|
maxRoundSecs: 30 * DAY_SECS,
|
|
381
393
|
minMinPotLamports: 100000000n,
|
|
382
394
|
maxMinPotLamports: 1000000000000n,
|
|
383
395
|
minPrizeBps: 1_000,
|
|
384
|
-
minClaimWindowSecs:
|
|
396
|
+
minClaimWindowSecs: 60,
|
|
385
397
|
maxClaimWindowSecs: DAY_SECS,
|
|
386
398
|
maxAttempts: 16,
|
|
387
399
|
claimsPerRound: 2,
|
|
400
|
+
/** A streak's epoch and its claim window keep the programs' older floors (`MIN_EPOCH_SECS`, `MIN_EPOCH_CLAIM_WINDOW`). */
|
|
401
|
+
minEpochSecs: 3_600,
|
|
402
|
+
minEpochClaimWindowSecs: 300,
|
|
388
403
|
/** The most a pot holds while its hook is not audited: 10 SOL, the rest of the pot's share bought back. */
|
|
389
404
|
potCapLamports: 10000000000n,
|
|
390
405
|
/** Lamports the setup pays for the hook's state and registry and the game's account (mainnet rent, 2026-10-08). */
|
|
391
406
|
rentLamports: 3515360n + 3119120n,
|
|
392
407
|
};
|
|
393
|
-
/** The lottery the launch form starts with (
|
|
394
|
-
export const LOTTERY_DEFAULTS = { kind: 'lottery', roundSecs:
|
|
408
|
+
/** The lottery the launch form starts with (the owner, 2026-10-12): 15-minute rounds, 70% of the fee to the pot, 30% bought back, draws from 0.5 SOL paying the whole pot, 3 claim attempts of 2 minutes (`lotteryClaimsFor`). */
|
|
409
|
+
export const LOTTERY_DEFAULTS = { kind: 'lottery', roundSecs: 900, potBps: 7_000, minPotLamports: '500000000', prizeBps: 10_000, claimWindowSecs: 120, maxAttempts: 3 };
|
|
395
410
|
/** The round lengths the form offers (seconds), each with its label. */
|
|
396
411
|
export const LOTTERY_ROUND_CHOICES = [
|
|
412
|
+
{ secs: 900, label: '15 min' },
|
|
413
|
+
{ secs: 1_800, label: '30 min' },
|
|
397
414
|
{ secs: 3_600, label: '1 hour' },
|
|
398
415
|
{ secs: 21_600, label: '6 hours' },
|
|
399
416
|
{ secs: DAY_SECS, label: '1 day' },
|
|
@@ -411,11 +428,19 @@ export const LOTTERY_PRIZE_CHOICES_BPS = [10_000, 5_000, 2_500];
|
|
|
411
428
|
/** The pot's share moves in steps of 10%, like the Split control's. */
|
|
412
429
|
export const LOTTERY_POT_STEP_BPS = 1_000;
|
|
413
430
|
/**
|
|
414
|
-
* The claim windows and attempts the form fixes for a round length
|
|
415
|
-
* round of 6 hours
|
|
431
|
+
* The claim windows and attempts the form fixes for a round length (the attempts must fit in half a
|
|
432
|
+
* round, `claimsPerRound`): 8 attempts of 10 minutes from 6 hours, 6 of 5 minutes from an hour, 3 of
|
|
433
|
+
* 2 minutes from 15 minutes, and 2 of a minute under that (the programs' 5-minute rounds, which the
|
|
434
|
+
* site does not offer).
|
|
416
435
|
*/
|
|
417
436
|
export function lotteryClaimsFor(roundSecs) {
|
|
418
|
-
|
|
437
|
+
if (roundSecs >= 21_600)
|
|
438
|
+
return { claimWindowSecs: 600, maxAttempts: 8 };
|
|
439
|
+
if (roundSecs >= 3_600)
|
|
440
|
+
return { claimWindowSecs: 300, maxAttempts: 6 };
|
|
441
|
+
if (roundSecs >= 900)
|
|
442
|
+
return { claimWindowSecs: 120, maxAttempts: 3 };
|
|
443
|
+
return { claimWindowSecs: 60, maxAttempts: 2 };
|
|
419
444
|
}
|
|
420
445
|
const isWhole = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
|
|
421
446
|
/** The lottery a launch request's `companion.game` names, each field a whole number; null when it is malformed (bounds are `lotteryProblem`'s). */
|
|
@@ -449,14 +474,14 @@ export function lotteryProblem(game, split, rules, creatorFeeBps) {
|
|
|
449
474
|
if (!(game.potBps > 0) || split.buybackBps + split.holdersBps + split.beneficiaryBps + game.potBps !== 10_000)
|
|
450
475
|
return 'The pot’s part and the split must add up to 10,000 basis points, with a pot.';
|
|
451
476
|
if (game.roundSecs < L.minRoundSecs || game.roundSecs > L.maxRoundSecs)
|
|
452
|
-
return 'A round lasts
|
|
477
|
+
return 'A round lasts 15 minutes to 30 days.';
|
|
453
478
|
const minPot = BigInt(game.minPotLamports);
|
|
454
479
|
if (minPot < L.minMinPotLamports || minPot > L.maxMinPotLamports)
|
|
455
480
|
return 'The minimum pot is 0.1 to 1,000 SOL.';
|
|
456
481
|
if (game.prizeBps < L.minPrizeBps || game.prizeBps > 10_000)
|
|
457
482
|
return 'A draw pays 10% to 100% of the pot.';
|
|
458
483
|
if (game.claimWindowSecs < L.minClaimWindowSecs || game.claimWindowSecs > L.maxClaimWindowSecs)
|
|
459
|
-
return 'A claim window lasts
|
|
484
|
+
return 'A claim window lasts a minute to a day.';
|
|
460
485
|
if (!(game.maxAttempts >= 1 && game.maxAttempts <= L.maxAttempts))
|
|
461
486
|
return 'A draw makes 1 to 16 attempts.';
|
|
462
487
|
if (game.claimWindowSecs * game.maxAttempts * L.claimsPerRound > game.roundSecs)
|
|
@@ -465,11 +490,11 @@ export function lotteryProblem(game, split, rules, creatorFeeBps) {
|
|
|
465
490
|
}
|
|
466
491
|
/** The copy rules of a lottery coin (docs/games.md): said on the launch form, the review step and the token page, never "provably fair", "guaranteed" or "audited". */
|
|
467
492
|
export const LOTTERY_COPY = {
|
|
468
|
-
held: 'The pot is held by
|
|
469
|
-
odds: 'Your odds
|
|
470
|
-
stop: 'Bordrless can stop
|
|
471
|
-
cap: '
|
|
472
|
-
oracle: '
|
|
493
|
+
held: 'The pot is held by code, not by a person. Each round, one holder is picked at random and paid in SOL.',
|
|
494
|
+
odds: 'Your odds are your share of the tokens held through the round. Tokens you buy count from the next round, and selling during a round takes you out of it.',
|
|
495
|
+
stop: 'Bordrless can stop the game. If it does, the pot buys back and burns the token and no one is paid.',
|
|
496
|
+
cap: 'The pot holds at most 10 SOL for now. Anything above that buys back and burns the token.',
|
|
497
|
+
oracle: 'The random numbers come from ORAO, an outside service. Neither Bordrless nor the launcher can pick the winner.',
|
|
473
498
|
};
|
|
474
499
|
// ---- jackpot and streak coins (docs/games.md "Phase 2"), on game hooks made in Studio -----------------
|
|
475
500
|
/** The jackpot's timer and the streak's minimum streak: the companion's bounds (`GAME_LIMITS` in the SDK). */
|
|
@@ -541,9 +566,9 @@ export function gameProblem(game, split, rules, creatorFeeBps, hook = null) {
|
|
|
541
566
|
return 'A qualifying buy is at least one base unit.';
|
|
542
567
|
return null;
|
|
543
568
|
}
|
|
544
|
-
if (hook.roundSecs < L.
|
|
569
|
+
if (hook.roundSecs < L.minEpochSecs || hook.roundSecs > L.maxRoundSecs)
|
|
545
570
|
return 'An epoch lasts an hour to 30 days.';
|
|
546
|
-
if (game.claimWindowSecs < L.
|
|
571
|
+
if (game.claimWindowSecs < L.minEpochClaimWindowSecs || game.claimWindowSecs > L.maxClaimWindowSecs || game.claimWindowSecs * L.claimsPerRound > hook.roundSecs)
|
|
547
572
|
return 'A streak leaves claims 5 minutes to a day, at most half an epoch.';
|
|
548
573
|
if (hook.minStreakSecs > GAME_KIND_LIMITS.maxMinStreakSecs)
|
|
549
574
|
return 'A streak asks at most a year without sending.';
|
|
@@ -560,31 +585,31 @@ export const GAME_KIND_NAMES = { lottery: 'lottery', jackpot: 'jackpot', streak:
|
|
|
560
585
|
* stop this game"; never "provably fair", "guaranteed" or "audited".
|
|
561
586
|
*/
|
|
562
587
|
export const JACKPOT_COPY = {
|
|
563
|
-
held: 'The pot is held by
|
|
564
|
-
rule: 'The last buyer wins
|
|
565
|
-
unfunded: 'If the pot is
|
|
566
|
-
curve: '
|
|
588
|
+
held: 'The pot is held by code, not by a person. No luck involved: the last buyer wins.',
|
|
589
|
+
rule: 'The last buyer wins when the timer runs out, as long as they still hold what they bought. Selling or sending any of it forfeits.',
|
|
590
|
+
unfunded: 'If the pot is under its minimum when the timer runs out, no prize is paid and the pot keeps growing.',
|
|
591
|
+
curve: 'The game runs while the coin is on its curve and ends at graduation. A round under way is still paid.',
|
|
567
592
|
cap: LOTTERY_COPY.cap,
|
|
568
593
|
stop: LOTTERY_COPY.stop,
|
|
569
594
|
};
|
|
570
595
|
export const STREAK_COPY = {
|
|
571
|
-
held: 'The pot is held by
|
|
572
|
-
rule: '
|
|
573
|
-
weight: 'Your
|
|
574
|
-
claims: 'Shares are
|
|
596
|
+
held: 'The pot is held by code, not by a person. No luck involved: holders share it in proportion to what they held.',
|
|
597
|
+
rule: 'Hold through the epoch without sending anything. Any sale or transfer drops you out of this epoch and the last one.',
|
|
598
|
+
weight: 'Your share is based on what you held when the epoch began. Tokens you buy during an epoch count from the next one.',
|
|
599
|
+
claims: 'Shares are paid out during the next epoch. Most are paid automatically; small ones are claimed here. Unclaimed shares stay in the pot.',
|
|
575
600
|
cap: LOTTERY_COPY.cap,
|
|
576
601
|
stop: LOTTERY_COPY.stop,
|
|
577
602
|
};
|
|
578
603
|
/** Why a round paid no prize, in a few words each (`GameRolloverReason`). */
|
|
579
604
|
export const ROLLOVER_WORDS = {
|
|
580
|
-
noTickets: '
|
|
581
|
-
roundForgotten: 'The round was
|
|
582
|
-
noClaim: '
|
|
583
|
-
oracleSilent: '
|
|
605
|
+
noTickets: 'No tickets yet: tokens count from the round after they are bought',
|
|
606
|
+
roundForgotten: 'The round was skipped',
|
|
607
|
+
noClaim: 'The winner did not claim in time',
|
|
608
|
+
oracleSilent: 'The random number never arrived',
|
|
584
609
|
blocked: 'Stopped by Bordrless',
|
|
585
610
|
late: 'Drawn too late',
|
|
586
|
-
oracleUnreadable: '
|
|
587
|
-
oracleUnpaid: 'The pot could not pay
|
|
611
|
+
oracleUnreadable: 'The random number could not be read',
|
|
612
|
+
oracleUnpaid: 'The pot could not pay for the random number',
|
|
588
613
|
};
|
|
589
614
|
/** Whether a split is the whole fee to the launcher (no companion needed). */
|
|
590
615
|
export const isLauncherSplit = (s) => s.buybackBps === 0 && s.holdersBps === 0 && s.beneficiaryBps === 10_000;
|
|
@@ -636,6 +661,366 @@ export function companionSplitProblem(split, rules, creatorFeeBps) {
|
|
|
636
661
|
return 'A share of the creator fee for holders needs holder rewards on.';
|
|
637
662
|
return null;
|
|
638
663
|
}
|
|
664
|
+
export const SNOWBALL_PRESETS = {
|
|
665
|
+
conservative: { buyStepBps: 300, sellStepBps: 300, buyBps: 1_000, sellBps: 1_000, poolBps: 100, interval: 60, flatTrades: true },
|
|
666
|
+
normal: { buyStepBps: 200, sellStepBps: 200, buyBps: 2_500, sellBps: 2_500, poolBps: 200, interval: 30, flatTrades: true },
|
|
667
|
+
aggressive: { buyStepBps: 100, sellStepBps: 100, buyBps: 5_000, sellBps: 5_000, poolBps: 500, interval: 10, flatTrades: true },
|
|
668
|
+
};
|
|
669
|
+
export const SNOWBALL_PRESET_LIST = ['conservative', 'normal', 'aggressive'];
|
|
670
|
+
export const SNOWBALL_DEFAULT_PRESET = 'normal';
|
|
671
|
+
/** The custom controls' choices. The program takes anything within `SNOWBALL_BOUNDS`. */
|
|
672
|
+
export const SNOWBALL_STEP_CHOICES_BPS = [50, 100, 200, 300, 500, 1_000];
|
|
673
|
+
export const SNOWBALL_SIZE_CHOICES_BPS = [1_000, 2_500, 5_000, 7_500, 10_000];
|
|
674
|
+
export const SNOWBALL_POOL_CHOICES_BPS = [100, 200, 500, 1_000];
|
|
675
|
+
export const SNOWBALL_INTERVAL_CHOICES_SECS = [10, 30, 60, 300];
|
|
676
|
+
/** The program's bounds (`create_snowball`). */
|
|
677
|
+
export const SNOWBALL_BOUNDS = { minStepBps: 50, maxStepBps: 2_000, minSizeBps: 100, maxSizeBps: 10_000, minPoolBps: 25, maxPoolBps: 1_000, minInterval: 10, maxInterval: 86_400 };
|
|
678
|
+
/** The Snowball a launch request's `companion` names; null without one; `'malformed'` when it is there and not whole numbers and a flag. */
|
|
679
|
+
export function snowballRequestOf(companion) {
|
|
680
|
+
if (typeof companion !== 'object' || companion === null)
|
|
681
|
+
return null;
|
|
682
|
+
const raw = companion.snowball;
|
|
683
|
+
if (raw === undefined || raw === null)
|
|
684
|
+
return null;
|
|
685
|
+
if (typeof raw !== 'object')
|
|
686
|
+
return 'malformed';
|
|
687
|
+
const r = raw;
|
|
688
|
+
if (![r.potBps, r.buyStepBps, r.sellStepBps, r.buyBps, r.sellBps, r.poolBps].every(isShare))
|
|
689
|
+
return 'malformed';
|
|
690
|
+
if (typeof r.interval !== 'number' || !Number.isInteger(r.interval) || typeof r.flatTrades !== 'boolean')
|
|
691
|
+
return 'malformed';
|
|
692
|
+
return { potBps: r.potBps, buyStepBps: r.buyStepBps, sellStepBps: r.sellStepBps, buyBps: r.buyBps, sellBps: r.sellBps, poolBps: r.poolBps, interval: r.interval, flatTrades: r.flatTrades };
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Why a Snowball with this split can't run these rules at this fee, in the words the launch form and
|
|
696
|
+
* the backend use (`companion_rules`); null when it can: the market maker's part with the split adds up
|
|
697
|
+
* to 10,000, its terms are within the program's bounds, and what any companion needs
|
|
698
|
+
* (`companionSplitProblem`'s rules).
|
|
699
|
+
*/
|
|
700
|
+
export function snowballProblem(s, split, rules, creatorFeeBps) {
|
|
701
|
+
const B = SNOWBALL_BOUNDS;
|
|
702
|
+
const within = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
|
|
703
|
+
if (!(s.potBps > 0) || split.buybackBps + split.holdersBps + split.beneficiaryBps + s.potBps !== 10_000)
|
|
704
|
+
return 'The market maker’s part and the rest of the creator fee must add up to 100%.';
|
|
705
|
+
if (!within(s.buyStepBps, B.minStepBps, B.maxStepBps) || !within(s.sellStepBps, B.minStepBps, B.maxStepBps))
|
|
706
|
+
return 'A buy or a sell triggers on a move of 0.5% to 20%.';
|
|
707
|
+
if (!within(s.buyBps, B.minSizeBps, B.maxSizeBps) || !within(s.sellBps, B.minSizeBps, B.maxSizeBps))
|
|
708
|
+
return 'Each trade uses 1% to 100% of its SOL or its tokens.';
|
|
709
|
+
if (!within(s.poolBps, B.minPoolBps, B.maxPoolBps))
|
|
710
|
+
return 'A trade can be up to 0.25% to 10% of the pool.';
|
|
711
|
+
if (!within(s.interval, B.minInterval, B.maxInterval))
|
|
712
|
+
return 'Trades are 10 seconds to a day apart.';
|
|
713
|
+
if (rules.creatorLockDays > 0)
|
|
714
|
+
return 'A creator fee run by a program can’t come with the creator wallet lock: the program vests your first buy instead.';
|
|
715
|
+
if (creatorFeeBps === 0)
|
|
716
|
+
return 'There is no creator fee to fill the market maker with: set one.';
|
|
717
|
+
if (splitPaysHolders(split) && !rewardsOn(rules))
|
|
718
|
+
return 'A share of the creator fee for holders needs holder rewards on.';
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
// ---- Custom companions (2026-10-12): a Workshop script spends the pot (docs/scripts.md) --------------
|
|
722
|
+
/** The companion program's script bounds (`create_script`): steps 10 seconds to a day apart, at most 512 bytes of init data, at most 10,240 bytes of state. */
|
|
723
|
+
export const SCRIPT_BOUNDS = { minInterval: 10, maxInterval: 86_400, maxInitLen: 512, maxStateLen: 10_240 };
|
|
724
|
+
/** The interval choices the launch form offers (seconds): a minute, five, fifteen, an hour, a day. */
|
|
725
|
+
export const SCRIPT_INTERVAL_CHOICES_SECS = [60, 300, 900, 3_600, 86_400];
|
|
726
|
+
const BASE58_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
727
|
+
const HEX_BYTES = /^(?:0x)?(?:[0-9a-fA-F]{2})*$/;
|
|
728
|
+
const BASE64_TEXT = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
729
|
+
/**
|
|
730
|
+
* How many bytes a script's init data holds, as the request writes it: hex (an even number of hex
|
|
731
|
+
* digits, `0x` or not) or base64; the empty string is no data. Null when it is neither. Hex is tried
|
|
732
|
+
* first: a text that reads as both (`abcd`) is read as hex.
|
|
733
|
+
*/
|
|
734
|
+
export function scriptInitDataLength(raw) {
|
|
735
|
+
if (raw === '')
|
|
736
|
+
return 0;
|
|
737
|
+
if (HEX_BYTES.test(raw))
|
|
738
|
+
return (raw.startsWith('0x') ? raw.length - 2 : raw.length) / 2;
|
|
739
|
+
if (BASE64_TEXT.test(raw))
|
|
740
|
+
return (raw.length / 4) * 3 - (raw.endsWith('==') ? 2 : raw.endsWith('=') ? 1 : 0);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
/** The custom companion's script a launch request's `companion` names; null without one; `'malformed'` when it is there and not the fields `ScriptRequest` takes. */
|
|
744
|
+
export function scriptRequestOf(companion) {
|
|
745
|
+
if (typeof companion !== 'object' || companion === null)
|
|
746
|
+
return null;
|
|
747
|
+
const raw = companion.script;
|
|
748
|
+
if (raw === undefined || raw === null)
|
|
749
|
+
return null;
|
|
750
|
+
if (typeof raw !== 'object')
|
|
751
|
+
return 'malformed';
|
|
752
|
+
const r = raw;
|
|
753
|
+
if (!isShare(r.potBps) || typeof r.program !== 'string' || !BASE58_ADDRESS.test(r.program))
|
|
754
|
+
return 'malformed';
|
|
755
|
+
if (typeof r.interval !== 'number' || !Number.isInteger(r.interval) || typeof r.stateLen !== 'number' || !Number.isInteger(r.stateLen))
|
|
756
|
+
return 'malformed';
|
|
757
|
+
const initData = r.initData === undefined || r.initData === null ? '' : r.initData;
|
|
758
|
+
if (typeof initData !== 'string' || scriptInitDataLength(initData) === null)
|
|
759
|
+
return 'malformed';
|
|
760
|
+
const payout = payoutTermsRequestOf(r.payout);
|
|
761
|
+
if (payout === 'malformed')
|
|
762
|
+
return 'malformed';
|
|
763
|
+
return { potBps: r.potBps, program: r.program, interval: r.interval, initData, stateLen: r.stateLen, ...(payout ? { payout } : {}) };
|
|
764
|
+
}
|
|
765
|
+
/** The companion program's bounds on payout terms (`PayoutTermsArgs::valid`): each share 1 to 10,000 basis points, the window 10 seconds to a day. */
|
|
766
|
+
export const PAYOUT_TERMS_BOUNDS = { minWindowSecs: 10, maxWindowSecs: 86_400 };
|
|
767
|
+
/** A script's payout terms as a request names them (`script.payout`); null without them; `'malformed'` when they are there and not four whole numbers. */
|
|
768
|
+
function payoutTermsRequestOf(raw) {
|
|
769
|
+
if (raw === undefined || raw === null)
|
|
770
|
+
return null;
|
|
771
|
+
if (typeof raw !== 'object')
|
|
772
|
+
return 'malformed';
|
|
773
|
+
const r = raw;
|
|
774
|
+
if (![r.stepBps, r.holdingBps, r.windowBps].every(isShare) || typeof r.windowSecs !== 'number' || !Number.isInteger(r.windowSecs))
|
|
775
|
+
return 'malformed';
|
|
776
|
+
return { stepBps: r.stepBps, holdingBps: r.holdingBps, windowBps: r.windowBps, windowSecs: r.windowSecs };
|
|
777
|
+
}
|
|
778
|
+
/** Whether payout terms are within the companion program's bounds (`create_script_v2` refuses them otherwise, `BadPayoutTerms`). */
|
|
779
|
+
export function payoutTermsWithin(t) {
|
|
780
|
+
const share = (v) => Number.isInteger(v) && v >= 1 && v <= 10_000;
|
|
781
|
+
return share(t.stepBps) && share(t.holdingBps) && share(t.windowBps) && Number.isInteger(t.windowSecs) && t.windowSecs >= PAYOUT_TERMS_BOUNDS.minWindowSecs && t.windowSecs <= PAYOUT_TERMS_BOUNDS.maxWindowSecs;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Why a custom companion with this split can't run these rules at this fee, in the words the launch
|
|
785
|
+
* form and the backend use (`companion_rules`); null when it can: the pot's part with the split adds
|
|
786
|
+
* up to 10,000, the interval, the init data and the state are within the program's bounds, and what
|
|
787
|
+
* any companion needs (`companionSplitProblem`'s rules). Whether the script is approved is the
|
|
788
|
+
* backend's to check (`script_not_approved`).
|
|
789
|
+
*/
|
|
790
|
+
export function scriptProblem(s, split, rules, creatorFeeBps) {
|
|
791
|
+
const B = SCRIPT_BOUNDS;
|
|
792
|
+
if (!(s.potBps > 0) || split.buybackBps + split.holdersBps + split.beneficiaryBps + s.potBps !== 10_000)
|
|
793
|
+
return 'The script’s part and the rest of the creator fee must add up to 100%.';
|
|
794
|
+
if (!Number.isInteger(s.interval) || s.interval < B.minInterval || s.interval > B.maxInterval)
|
|
795
|
+
return 'Steps are 10 seconds to a day apart.';
|
|
796
|
+
const initLen = scriptInitDataLength(s.initData);
|
|
797
|
+
if (initLen === null)
|
|
798
|
+
return 'The script’s init data is hex or base64.';
|
|
799
|
+
if (initLen > B.maxInitLen)
|
|
800
|
+
return `The script’s init data is at most ${B.maxInitLen} bytes.`;
|
|
801
|
+
if (!Number.isInteger(s.stateLen) || s.stateLen < 0 || s.stateLen > B.maxStateLen)
|
|
802
|
+
return `The script’s state is 0 to ${B.maxStateLen.toLocaleString('en-US')} bytes.`;
|
|
803
|
+
if (s.payout && !payoutTermsWithin(s.payout))
|
|
804
|
+
return 'The script’s payout limits are each 0.01% to 100%, over a window of 10 seconds to a day.';
|
|
805
|
+
if (rules.creatorLockDays > 0)
|
|
806
|
+
return 'A creator fee run by a program can’t come with the creator wallet lock: the program vests your first buy instead.';
|
|
807
|
+
if (creatorFeeBps === 0)
|
|
808
|
+
return 'There is no creator fee for the script to spend: set one.';
|
|
809
|
+
if (splitPaysHolders(split) && !rewardsOn(rules))
|
|
810
|
+
return 'A share of the creator fee for holders needs holder rewards on.';
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
// ---- Payees and wallet payouts (2026-10-13, docs/payees-and-wallet-payouts.md) ---------------------
|
|
814
|
+
/** The most wallets that share a launcher's part (`set_payees`; the SDK's `MAX_PAYEES`). */
|
|
815
|
+
export const MAX_PAYEES = 10;
|
|
816
|
+
/** The system program's id, the default key: never a payee. */
|
|
817
|
+
const DEFAULT_KEY = '11111111111111111111111111111111';
|
|
818
|
+
/** The payees a launch request's `companion` names (`companion.payees`); null without any (absent, null or empty); `'malformed'` when they are there and not a list of `{ wallet, bps }` with an address and a whole number. */
|
|
819
|
+
export function payeesRequestOf(companion) {
|
|
820
|
+
if (typeof companion !== 'object' || companion === null)
|
|
821
|
+
return null;
|
|
822
|
+
const raw = companion.payees;
|
|
823
|
+
if (raw === undefined || raw === null)
|
|
824
|
+
return null;
|
|
825
|
+
if (!Array.isArray(raw))
|
|
826
|
+
return 'malformed';
|
|
827
|
+
if (raw.length === 0)
|
|
828
|
+
return null;
|
|
829
|
+
const out = [];
|
|
830
|
+
for (const entry of raw) {
|
|
831
|
+
if (typeof entry !== 'object' || entry === null)
|
|
832
|
+
return 'malformed';
|
|
833
|
+
const e = entry;
|
|
834
|
+
if (typeof e.wallet !== 'string' || !BASE58_ADDRESS.test(e.wallet) || typeof e.bps !== 'number' || !Number.isInteger(e.bps))
|
|
835
|
+
return 'malformed';
|
|
836
|
+
out.push({ wallet: e.wallet, bps: e.bps });
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
/** What the payees leave the launcher of their part, basis points of it (10,000 with none). */
|
|
841
|
+
export const payeesLeaveBps = (payees) => 10_000 - payees.reduce((sum, p) => sum + p.bps, 0);
|
|
842
|
+
/**
|
|
843
|
+
* Why `set_payees` would refuse these payees (`BadPayees`), in the words the launch form and the
|
|
844
|
+
* backend use (`companion_rules`); null when it would take them: 1 to 10 wallets, each named once,
|
|
845
|
+
* each 0.01% to 100% of the launcher's part and 100% in all at most; none the default key, the
|
|
846
|
+
* launcher (`beneficiary`) or one of `reserved` (the companion and its creator address, which the
|
|
847
|
+
* backend derives for the mint). A split with no part for the launcher has nothing to share.
|
|
848
|
+
*/
|
|
849
|
+
export function payeesProblem(payees, split, beneficiary, reserved = []) {
|
|
850
|
+
if (payees.length === 0 || payees.length > MAX_PAYEES)
|
|
851
|
+
return `Up to ${MAX_PAYEES} wallets can share your part.`;
|
|
852
|
+
if (split.beneficiaryBps <= 0)
|
|
853
|
+
return 'The wallets share your part of the creator fee, and this split leaves you none.';
|
|
854
|
+
if (payees.some((p) => !Number.isInteger(p.bps) || p.bps < 1 || p.bps > 10_000))
|
|
855
|
+
return 'Each wallet gets 0.01% to 100% of your part.';
|
|
856
|
+
if (payeesLeaveBps(payees) < 0)
|
|
857
|
+
return 'The wallets’ shares add up to more than 100% of your part.';
|
|
858
|
+
const seen = new Set();
|
|
859
|
+
for (const p of payees) {
|
|
860
|
+
if (!BASE58_ADDRESS.test(p.wallet) || p.wallet === DEFAULT_KEY)
|
|
861
|
+
return `${p.wallet} is not a wallet.`;
|
|
862
|
+
if (seen.has(p.wallet))
|
|
863
|
+
return 'Name each wallet once.';
|
|
864
|
+
seen.add(p.wallet);
|
|
865
|
+
if (p.wallet === beneficiary)
|
|
866
|
+
return 'You keep what the wallets don’t take: name wallets other than your own.';
|
|
867
|
+
if (reserved.includes(p.wallet))
|
|
868
|
+
return 'A wallet can’t be the hook’s own account.';
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Why a request's `allowWalletPayouts` can't be taken (`companion_rules`); null when it can, or it
|
|
874
|
+
* is off: only a custom hook's script with payout terms (`script.payout`, `create_script_v2`) may
|
|
875
|
+
* pay wallets, never Lossback's (its script pays holders under water, and nothing else).
|
|
876
|
+
*/
|
|
877
|
+
export function walletPayoutsProblem(allow, script) {
|
|
878
|
+
if (!allow)
|
|
879
|
+
return null;
|
|
880
|
+
if (!script)
|
|
881
|
+
return 'Only a custom hook’s script can pay wallets.';
|
|
882
|
+
if (!script.payout)
|
|
883
|
+
return 'A script pays wallets only within payout limits: set them.';
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
// ---- Loss cover (2026-10-12; the site says Lossback): the pot pays holders who are under water (docs/loss-cover.md) ----
|
|
887
|
+
/**
|
|
888
|
+
* The bounds a Lossback coin's terms keep within, as the site offers them: the programs' own
|
|
889
|
+
* (`loss_cover_script::CoverTerms::valid`, the companion's `PayoutTermsArgs::valid`) and the
|
|
890
|
+
* policy's tighter ones (the step at least 10 s and at most a quarter of the round, a holding of
|
|
891
|
+
* at most 1% of the supply, a loss of at least 0.0001 SOL, a wait of at most a day). The keeper's
|
|
892
|
+
* batches (`batchPaying`, `batchMeasuring`) are what one step carries within the runtime's
|
|
893
|
+
* instruction trace, measured by the programs' `loss_cover_vectors` (a measuring step carries one
|
|
894
|
+
* fewer on a coin's first step, when the creator's holding is made on the way).
|
|
895
|
+
*/
|
|
896
|
+
export const LOSS_COVER_LIMITS = {
|
|
897
|
+
minRoundSecs: 60,
|
|
898
|
+
maxRoundSecs: DAY_SECS,
|
|
899
|
+
minStepSecs: 10,
|
|
900
|
+
maxStepSecs: 3_600,
|
|
901
|
+
/** The step is at most this part of the round: a round has at least four steps. */
|
|
902
|
+
stepsPerRoundMin: 4,
|
|
903
|
+
minBps: 100,
|
|
904
|
+
maxBps: 10_000,
|
|
905
|
+
minCoverBps: 1_000,
|
|
906
|
+
maxCoverBps: 10_000,
|
|
907
|
+
/** The least holding that counts, at most this part of the supply (basis points). */
|
|
908
|
+
maxMinHoldingBps: 100,
|
|
909
|
+
minMinLossLamports: 100000n,
|
|
910
|
+
maxMinAgeSecs: DAY_SECS,
|
|
911
|
+
maxMinPotLamports: 100000000000n,
|
|
912
|
+
batchPaying: 7,
|
|
913
|
+
batchMeasuring: 13,
|
|
914
|
+
};
|
|
915
|
+
/**
|
|
916
|
+
* The terms the site launches a Lossback coin with: rounds of 5 minutes stepped every 15 seconds,
|
|
917
|
+
* the whole pot available to each step, each holding and each round, the whole loss covered when
|
|
918
|
+
* the pot can, a holding of 100,000 tokens (6 decimals: base units) and a loss of 0.001 SOL at
|
|
919
|
+
* least, a minute after its last buy (the owner, 2026-10-12; the first coin, LIFEVEST, was made with
|
|
920
|
+
* half an hour, fixed in its script state), a round only once the pot holds 1 SOL, the hook's whole
|
|
921
|
+
* fee row to the pot (the launcher's row 0: `potBps` is 10,000 less the launcher's part). The
|
|
922
|
+
* launcher sets `roundSecs`, `minPotLamports`, `coverBps` and `minHolding` (`LossCoverRequest`);
|
|
923
|
+
* the rest is fixed here. There is no cooldown: a holding is measured or paid once a round.
|
|
924
|
+
*/
|
|
925
|
+
/** A holding's wait in words: "45 seconds", "1 minute", "30 minutes". */
|
|
926
|
+
export function ageWords(secs) {
|
|
927
|
+
if (secs % 60 !== 0 || secs < 60)
|
|
928
|
+
return `${secs} second${secs === 1 ? '' : 's'}`;
|
|
929
|
+
const m = secs / 60;
|
|
930
|
+
return `${m} minute${m === 1 ? '' : 's'}`;
|
|
931
|
+
}
|
|
932
|
+
export const LOSS_COVER_DEFAULTS = {
|
|
933
|
+
potBps: 10_000,
|
|
934
|
+
roundSecs: 300,
|
|
935
|
+
stepSecs: 15,
|
|
936
|
+
roundBps: 10_000,
|
|
937
|
+
stepBps: 10_000,
|
|
938
|
+
holdingBps: 10_000,
|
|
939
|
+
coverBps: 10_000,
|
|
940
|
+
minHolding: '100000000000',
|
|
941
|
+
minLossLamports: '1000000',
|
|
942
|
+
minAgeSecs: 60,
|
|
943
|
+
minPotLamports: '1000000000',
|
|
944
|
+
};
|
|
945
|
+
/** The round lengths the launch form offers (seconds): 5, 10, 30 and 60 minutes. */
|
|
946
|
+
export const LOSS_COVER_ROUND_CHOICES_SECS = [300, 600, 1_800, 3_600];
|
|
947
|
+
/** The cover shares the launch form offers: the whole loss, half, a quarter. */
|
|
948
|
+
export const LOSS_COVER_COVER_CHOICES_BPS = [10_000, 5_000, 2_500];
|
|
949
|
+
/** The minimum pots the launch form offers, lamports: none, 0.5, 1, 5 SOL. */
|
|
950
|
+
export const LOSS_COVER_MIN_POT_CHOICES_LAMPORTS = ['0', '500000000', '1000000000', '5000000000'];
|
|
951
|
+
const LAMPORTS = /^\d{1,20}$/;
|
|
952
|
+
/** The Lossback terms a launch request's `companion` names (`companion.lossCover`); null without them; `'malformed'` when they are there and not whole numbers and lamport strings. */
|
|
953
|
+
export function lossCoverRequestOf(companion) {
|
|
954
|
+
if (typeof companion !== 'object' || companion === null)
|
|
955
|
+
return null;
|
|
956
|
+
const raw = companion.lossCover;
|
|
957
|
+
if (raw === undefined || raw === null)
|
|
958
|
+
return null;
|
|
959
|
+
if (typeof raw !== 'object')
|
|
960
|
+
return 'malformed';
|
|
961
|
+
const r = raw;
|
|
962
|
+
if (typeof r.roundSecs !== 'number' || !Number.isInteger(r.roundSecs) || !isShare(r.coverBps))
|
|
963
|
+
return 'malformed';
|
|
964
|
+
if (typeof r.minPotLamports !== 'string' || !LAMPORTS.test(r.minPotLamports) || typeof r.minHolding !== 'string' || !LAMPORTS.test(r.minHolding))
|
|
965
|
+
return 'malformed';
|
|
966
|
+
return { roundSecs: r.roundSecs, minPotLamports: r.minPotLamports, coverBps: r.coverBps, minHolding: r.minHolding };
|
|
967
|
+
}
|
|
968
|
+
/** The full terms a request launches with: what it names, the rest from `LOSS_COVER_DEFAULTS`. */
|
|
969
|
+
export function lossCoverTermsOf(s) {
|
|
970
|
+
const D = LOSS_COVER_DEFAULTS;
|
|
971
|
+
return { roundSecs: s.roundSecs, stepSecs: D.stepSecs, stepBps: D.stepBps, holdingBps: D.holdingBps, roundBps: D.roundBps, coverBps: s.coverBps, minHolding: s.minHolding, minLossLamports: D.minLossLamports, minAgeSecs: D.minAgeSecs, minPotLamports: s.minPotLamports };
|
|
972
|
+
}
|
|
973
|
+
/** The pot's part of every fee claim for a Lossback coin: what the launcher's part of the split leaves (the hook's whole fee row). */
|
|
974
|
+
export const lossCoverPotBps = (split) => 10_000 - split.buybackBps - split.holdersBps - split.beneficiaryBps;
|
|
975
|
+
/**
|
|
976
|
+
* Why a Lossback coin with these terms can't launch with this split on these rules at this fee, in
|
|
977
|
+
* the words the launch form and the backend use (`companion_rules`); null when it can: a creator
|
|
978
|
+
* fee to fill the pot, no other token rules (it launches from Bordrless's Loss cover config, which
|
|
979
|
+
* has none, and runs its own hook in the kit's place), nothing bought back and no holders' part (the
|
|
980
|
+
* pot and the launcher only), a part for the pot, and the terms within `LOSS_COVER_LIMITS` (the
|
|
981
|
+
* programs refuse anything outside their own bounds at the setup's dry run). `supply` is the
|
|
982
|
+
* launch's, for the least holding's cap.
|
|
983
|
+
*/
|
|
984
|
+
export function lossCoverProblem(s, split, rules, creatorFeeBps, supply = TOKEN_SUPPLY) {
|
|
985
|
+
const L = LOSS_COVER_LIMITS;
|
|
986
|
+
const D = LOSS_COVER_DEFAULTS;
|
|
987
|
+
if (creatorFeeBps === 0)
|
|
988
|
+
return 'There is no creator fee to fill the pot with: set one.';
|
|
989
|
+
if (Object.keys(NO_RULES).some((k) => rules[k] !== 0))
|
|
990
|
+
return 'A Lossback coin has no other token rules: its hook keeps each holding’s entry price.';
|
|
991
|
+
if (split.buybackBps !== 0 || split.holdersBps !== 0)
|
|
992
|
+
return 'A Lossback coin’s creator fee goes to its pot and to the launcher: nothing bought back, no holders’ part.';
|
|
993
|
+
const potBps = lossCoverPotBps(split);
|
|
994
|
+
if (!(potBps > 0) || !isShare(split.beneficiaryBps))
|
|
995
|
+
return 'The pot’s part and the launcher’s must add up to 100%, with a part for the pot.';
|
|
996
|
+
if (!Number.isInteger(s.roundSecs) || s.roundSecs < L.minRoundSecs || s.roundSecs > L.maxRoundSecs)
|
|
997
|
+
return 'A round lasts a minute to a day.';
|
|
998
|
+
if (s.roundSecs < D.stepSecs * L.stepsPerRoundMin)
|
|
999
|
+
return `A round holds at least ${L.stepsPerRoundMin} steps of ${D.stepSecs} seconds.`;
|
|
1000
|
+
if (!Number.isInteger(s.coverBps) || s.coverBps < L.minCoverBps || s.coverBps > L.maxCoverBps)
|
|
1001
|
+
return 'The pot covers 10% to 100% of a loss.';
|
|
1002
|
+
const minHolding = BigInt(s.minHolding);
|
|
1003
|
+
if (minHolding < 1n || minHolding > (supply * BigInt(L.maxMinHoldingBps)) / BPS)
|
|
1004
|
+
return 'The least holding that counts is at least one base unit and at most 1% of the supply.';
|
|
1005
|
+
const minPot = BigInt(s.minPotLamports);
|
|
1006
|
+
if (minPot > L.maxMinPotLamports)
|
|
1007
|
+
return 'The minimum pot is at most 100 SOL.';
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* The review step's sentence for a Lossback coin, in numbers: what it does, never that it protects
|
|
1012
|
+
* anyone (docs/loss-cover.md §5).
|
|
1013
|
+
*/
|
|
1014
|
+
export function lossCoverSentence(s, decimals = TOKEN_DECIMALS) {
|
|
1015
|
+
const D = LOSS_COVER_DEFAULTS;
|
|
1016
|
+
const minutes = Math.round(s.roundSecs / 60);
|
|
1017
|
+
const pot = Number(BigInt(s.minPotLamports)) / 1e9;
|
|
1018
|
+
const holding = Number(BigInt(s.minHolding)) / 10 ** decimals;
|
|
1019
|
+
const loss = Number(BigInt(D.minLossLamports)) / 1e9;
|
|
1020
|
+
const share = s.coverBps === 10_000 ? 'the same share of what they are down' : `the same share of ${s.coverBps / 100}% of what they are down`;
|
|
1021
|
+
const once = pot > 0 ? `, once the pot holds ${pot} SOL,` : '';
|
|
1022
|
+
return `Every ${minutes} minutes${once} it pays every holder under water ${share}, as much as the pot allows, in steps of ${D.stepSecs} seconds; a holding counts from ${holding.toLocaleString('en-US')} tokens and ${loss} SOL of loss, ${ageWords(D.minAgeSecs)} after it bought.`;
|
|
1023
|
+
}
|
|
639
1024
|
/** The template whose split this is, or null for any other split. */
|
|
640
1025
|
export function companionTemplateOf(split) {
|
|
641
1026
|
return COMPANION_TEMPLATE_LIST.find((t) => {
|