@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/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 companion program's game limits (`constants.rs`; `GAME_LIMITS` in the SDK, the same numbers):
374
- * a round's length, the minimum pot, the prize's least share, a claim window's bounds, the most
375
- * attempts, and the half of a round the attempts may take. The launch form holds its choices to
376
- * them and the backend refuses anything outside them (`lotteryProblem`).
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: 3_600,
391
+ minRoundSecs: 900,
380
392
  maxRoundSecs: 30 * DAY_SECS,
381
393
  minMinPotLamports: 100000000n,
382
394
  maxMinPotLamports: 1000000000000n,
383
395
  minPrizeBps: 1_000,
384
- minClaimWindowSecs: 300,
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 (docs/games.md, spec example 1): 6-hour rounds, 70% of the fee to the pot, 30% bought back, draws from 0.5 SOL paying the whole pot, 8 claim attempts of 10 minutes. */
394
- export const LOTTERY_DEFAULTS = { kind: 'lottery', roundSecs: 21_600, potBps: 7_000, minPotLamports: '500000000', prizeBps: 10_000, claimWindowSecs: 600, maxAttempts: 8 };
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: 8 attempts of 10 minutes for a
415
- * round of 6 hours or more, 6 of 5 minutes for an hour (the attempts must fit in half a round).
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
- return roundSecs >= 21_600 ? { claimWindowSecs: 600, maxAttempts: 8 } : { claimWindowSecs: 300, maxAttempts: 6 };
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 an hour to 30 days.';
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 5 minutes to a day.';
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 the companion program and paid out by code. Randomness by ORAO VRF.',
469
- odds: 'Your odds = your tokens in the round. Sell before the payout and you forfeit.',
470
- stop: 'Bordrless can stop this game: its pot would be bought back and burned. Nobody is paid.',
471
- cap: 'Pot capped at 10 SOL until the game’s hook is audited.',
472
- oracle: 'Randomness by ORAO VRF: ORAO’s three signers produce it. They could withhold or bias a draw; Bordrless can’t.',
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.minRoundSecs || hook.roundSecs > L.maxRoundSecs)
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.minClaimWindowSecs || game.claimWindowSecs > L.maxClaimWindowSecs || game.claimWindowSecs * L.claimsPerRound > hook.roundSecs)
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 the companion program and paid out by code. No randomness: the last buyer wins.',
564
- rule: 'The last buyer wins if they keep every token they bought: any sale or transfer forfeits.',
565
- unfunded: 'If the pot is below its minimum when a round is settled, the round pays nothing and the pot carries on.',
566
- curve: 'Buys count while the coin is on its curve: the jackpot ends at graduation (the round under way is still paid).',
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 the companion program and paid out by code. No randomness: holders share in proportion to what they held.',
572
- rule: 'Send anything, to anyone, and you lose this epoch’s and last epoch’s share. Claim before you sell.',
573
- weight: 'Your weight is what you held since the epoch began: tokens that arrive during an epoch count from the next.',
574
- claims: 'Shares are claimed during the next epoch; the keeper claims for everyone whose share covers the fee, the rest claim here. What is not claimed rolls over.',
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: 'Nobody held tickets',
581
- roundForgotten: 'The round was forgotten',
582
- noClaim: 'No winner claimed in time',
583
- oracleSilent: 'ORAO never answered',
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: 'ORAO’s answer unreadable',
587
- oracleUnpaid: 'The pot could not pay ORAO',
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) => {