@bordrless/shared 0.9.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/keeper.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * What a companion script asks Bordrless's keeper to bring to each step (docs/scripts.md "What a
3
+ * script may ask the keeper for"): a script that pays holders needs their holdings (and where each
4
+ * is paid) among its step's remaining accounts, and only the sender of a step chooses those. Builders
5
+ * never run keepers and Bordrless writes no code per submission, so the script says it in data:
6
+ * **`keeper.json`**, at the root of the submitted source, beside `src/` (where Cargo.toml sits in a
7
+ * crate). The name says who reads it (Bordrless's keeper); JSON because the Workshop, the site, the
8
+ * SDK and the keeper all read it with no parser of their own; outside `src/` because it is not Rust
9
+ * and the build never sees it. It travels with the source: the review by AI reads it beside the code,
10
+ * its bytes are part of the source's hash (so a changed declaration is reviewed and built again), and
11
+ * the approval binds it (`workshop_items.approved_keeper_hash`): a declaration changed after approval
12
+ * does not apply.
13
+ *
14
+ * A declaration says, once (version 1):
15
+ *
16
+ * - which holdings a step brings: the least and the most balance (base units, or a field of the
17
+ * script's state), a filter on each holding's 64 bytes of hook data (an unsigned little-endian
18
+ * field compared with a constant, the pool's price now, or the current epoch, floor(now / N), N a
19
+ * constant or a field of the script's state), and where each one is paid (its owner's wallet, its
20
+ * owner's bridged-SOL holding, or nowhere: the holding alone);
21
+ * - how many a step brings, with the launch pool's swap accounts and without them, and whether it
22
+ * carries them (a script that trades its pot), the token's hook accounts (a script that moves the
23
+ * token without trading: a receipt), or the companion's payout accounts (a script made with
24
+ * `createScriptV2` whose hook records payouts);
25
+ * - the round the keeper's cursor rotates by (seconds, or a field of the script's state);
26
+ * - the wallets a step brings for the script's wallet answers (the companion's
27
+ * `allow_wallet_payouts`, 2026-10-13: a script that pays any wallet SOL within its payout terms
28
+ * names each by its key, which must be among the step's accounts): fixed addresses, or public keys
29
+ * in the script's state. A declaration with wallets may bring no holdings (`holdings` null).
30
+ *
31
+ * `parseKeeperDeclaration` holds a file to the schema and its bounds and answers the normal form (the
32
+ * same schema, every default written out, amounts as strings of digits) or one sentence saying what
33
+ * is wrong; `keeperWords` says a declaration in plain words. No Solana dependencies: the keeper's own
34
+ * reading of a declaration is apps/server/src/keeper/scriptExtras.ts.
35
+ */
36
+ import { SCRIPT_BOUNDS, TOKEN_DECIMALS } from "./policy.js";
37
+ import { HOOK_DATA_LEN, PROGRAM_IDS } from "./programs.js";
38
+ import { decimalWords } from "./rules.js";
39
+ /** The file, at the root of the submitted source (beside `src/`): a companion script's, or a pair's companion half's. */
40
+ export const KEEPER_FILE = 'keeper.json';
41
+ /** The schema's version: the only one there is. */
42
+ export const KEEPER_VERSION = 1;
43
+ /** The bounds a declaration is held to. */
44
+ export const KEEPER_LIMITS = {
45
+ /** The file's bytes (UTF-8). */
46
+ bytes: 4_096,
47
+ /** Holdings one step may bring (the keeper brings fewer when a step would not fit one transaction). */
48
+ perStep: 16,
49
+ /** A round or an epoch, seconds: from the shortest step interval to a year. */
50
+ minSecs: SCRIPT_BOUNDS.minInterval,
51
+ maxSecs: 31_536_000,
52
+ /** The unit a hook-data field counts time in, seconds (an epoch's stamp: 300 for five minutes). */
53
+ maxUnitSecs: 31_536_000,
54
+ /** Wallets a step may bring (about 12 fit one transaction beside the protocol's lookup table on a plain coin; the keeper brings fewer when they do not). */
55
+ wallets: 12,
56
+ };
57
+ const U64_MAX = (1n << 64n) - 1n;
58
+ const WIDTHS = [1, 2, 4, 8];
59
+ export const KEEPER_COMPARES = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'];
60
+ export const KEEPER_PAY_TO = ['none', 'owner', 'ownerQuoteHolding'];
61
+ class KeeperProblem extends Error {
62
+ }
63
+ /** Refuses with one sentence, the file named first. */
64
+ const fail = (sentence) => {
65
+ throw new KeeperProblem(`${KEEPER_FILE}: ${sentence}`);
66
+ };
67
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
68
+ /** An object with no field but `allowed`. */
69
+ function fieldsOf(v, at, allowed, shape) {
70
+ if (!isObject(v))
71
+ return fail(`${at} is ${shape}.`);
72
+ for (const key of Object.keys(v))
73
+ if (!allowed.includes(key))
74
+ fail(`${at} has no field "${key.slice(0, 40)}" (it takes ${allowed.join(', ')}).`);
75
+ return v;
76
+ }
77
+ const group = (n) => n.toLocaleString('en-US');
78
+ function stateFieldOf(v, at) {
79
+ const o = fieldsOf(v, at, ['offset', 'width'], '{ "offset", "width" }: a field of the script’s state');
80
+ const width = o.width;
81
+ if (typeof width !== 'number' || !WIDTHS.includes(width))
82
+ return fail(`${at}.width is 1, 2, 4 or 8: the field’s bytes, unsigned little-endian.`);
83
+ const offset = o.offset;
84
+ const last = SCRIPT_BOUNDS.maxStateLen - width;
85
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0 || offset > last)
86
+ return fail(`${at}.offset is a whole number from 0 to ${group(last)}: where the field starts in the script’s state.`);
87
+ return { offset, width: width };
88
+ }
89
+ /** `{ "state": { "offset", "width" } }`. */
90
+ const fromState = (v, at) => {
91
+ const o = fieldsOf(v, at, ['state'], '{ "state": { "offset", "width" } }');
92
+ return { state: stateFieldOf(o.state, `${at}.state`) };
93
+ };
94
+ function amountOf(v, at, min) {
95
+ if (isObject(v))
96
+ return fromState(v, at);
97
+ let n = null;
98
+ if (typeof v === 'string' && /^\d{1,20}$/.test(v))
99
+ n = BigInt(v);
100
+ else if (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0)
101
+ n = BigInt(v);
102
+ if (n === null || n < min || n > U64_MAX)
103
+ return fail(`${at} is a whole number of base units from ${group(min)} to ${group(U64_MAX)}, written as a string of digits ("20000000000000"), or { "state": { "offset", "width" } }.`);
104
+ return n.toString();
105
+ }
106
+ function secondsOf(v, at) {
107
+ if (isObject(v))
108
+ return fromState(v, at);
109
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < KEEPER_LIMITS.minSecs || v > KEEPER_LIMITS.maxSecs) {
110
+ return fail(`${at} is a whole number of seconds from ${KEEPER_LIMITS.minSecs} to ${group(KEEPER_LIMITS.maxSecs)} (a year), or { "state": { "offset", "width" } }.`);
111
+ }
112
+ return v;
113
+ }
114
+ function againstOf(v, at) {
115
+ if (v === 'poolPrice')
116
+ return 'poolPrice';
117
+ if (!isObject(v) || Object.keys(v).length !== 1)
118
+ return fail(`${at} is "poolPrice", { "const": "<base units>" } or { "epoch": { "secs", "fieldUnitSecs" } }.`);
119
+ if ('const' in v) {
120
+ if (isObject(v.const))
121
+ return fail(`${at}.const is a string of digits: a constant, not a field of the state.`);
122
+ return { const: amountOf(v.const, `${at}.const`, 0n) };
123
+ }
124
+ if ('epoch' in v) {
125
+ const e = fieldsOf(v.epoch, `${at}.epoch`, ['secs', 'fieldUnitSecs'], '{ "secs", "fieldUnitSecs" }');
126
+ const secs = secondsOf(e.secs, `${at}.epoch.secs`);
127
+ const unit = e.fieldUnitSecs;
128
+ if (unit !== undefined && unit !== null && (typeof unit !== 'number' || !Number.isInteger(unit) || unit < 1 || unit > KEEPER_LIMITS.maxUnitSecs)) {
129
+ return fail(`${at}.epoch.fieldUnitSecs is a whole number of seconds from 1 to ${group(KEEPER_LIMITS.maxUnitSecs)}: the unit the field counts time in (300 when the hook stamps floor(now / 300)).`);
130
+ }
131
+ return { epoch: { secs, fieldUnitSecs: typeof unit === 'number' ? unit : null } };
132
+ }
133
+ return fail(`${at} is "poolPrice", { "const": "<base units>" } or { "epoch": { "secs", "fieldUnitSecs" } }.`);
134
+ }
135
+ function hookDataOf(v, at) {
136
+ if (v === undefined || v === null)
137
+ return null;
138
+ const o = fieldsOf(v, at, ['offset', 'width', 'compare', 'against'], '{ "offset", "width", "compare", "against" }, or null');
139
+ const width = o.width;
140
+ if (typeof width !== 'number' || !WIDTHS.includes(width))
141
+ return fail(`${at}.width is 1, 2, 4 or 8: the field’s bytes, unsigned little-endian.`);
142
+ const offset = o.offset;
143
+ const last = HOOK_DATA_LEN - width;
144
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0 || offset > last)
145
+ return fail(`${at}.offset is a whole number from 0 to ${last}: a holding keeps ${HOOK_DATA_LEN} bytes of hook data.`);
146
+ if (typeof o.compare !== 'string' || !KEEPER_COMPARES.includes(o.compare))
147
+ return fail(`${at}.compare is one of ${KEEPER_COMPARES.join(', ')}.`);
148
+ return { offset, width: width, compare: o.compare, against: againstOf(o.against, `${at}.against`) };
149
+ }
150
+ function perStepCount(v, at) {
151
+ if (v === undefined || v === null)
152
+ return null;
153
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > KEEPER_LIMITS.perStep)
154
+ return fail(`${at} is a whole number from 1 to ${KEEPER_LIMITS.perStep}.`);
155
+ return v;
156
+ }
157
+ function flagOf(v, at, fallback) {
158
+ if (v === undefined && fallback !== null)
159
+ return fallback;
160
+ if (typeof v !== 'boolean')
161
+ return fail(`${at} is true or false.`);
162
+ return v;
163
+ }
164
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
165
+ /** Whether a text is a public key in base58: exactly 32 bytes, as `new PublicKey` reads it (each leading "1" a zero byte). */
166
+ export function isBase58Key(text) {
167
+ if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(text))
168
+ return false;
169
+ let n = 0n;
170
+ for (const ch of text)
171
+ n = n * 58n + BigInt(BASE58.indexOf(ch));
172
+ let bytes = text.length - text.replace(/^1+/, '').length;
173
+ for (; n > 0n; n >>= 8n)
174
+ bytes += 1;
175
+ return bytes === 32;
176
+ }
177
+ /** The default key (the system program's): never a wallet a step pays. */
178
+ const DEFAULT_KEY = '11111111111111111111111111111111';
179
+ const PROTOCOL_KEYS = Object.values(PROGRAM_IDS);
180
+ /** `wallets`: at most `KEEPER_LIMITS.wallets`, each an address or 32 bytes of the script's state, none named twice; [] when absent. */
181
+ function walletsOf(v, at) {
182
+ if (v === undefined || v === null)
183
+ return [];
184
+ if (!Array.isArray(v) || v.length > KEEPER_LIMITS.wallets)
185
+ return fail(`${at} is a list of at most ${KEEPER_LIMITS.wallets} wallets, each an address ("<base58>") or { "state": { "offset" } }: a public key in the script’s state.`);
186
+ const out = [];
187
+ const seen = new Set();
188
+ v.forEach((w, i) => {
189
+ const where = `${at}[${i}]`;
190
+ let wallet;
191
+ if (typeof w === 'string') {
192
+ if (!isBase58Key(w))
193
+ fail(`${where} is an address in base58 (32 bytes).`);
194
+ if (w === DEFAULT_KEY || PROTOCOL_KEYS.includes(w))
195
+ fail(`${where} is the default key or a Bordrless program: the companion never pays it.`);
196
+ wallet = w;
197
+ }
198
+ else {
199
+ const o = fieldsOf(w, where, ['state'], 'an address ("<base58>") or { "state": { "offset" } }');
200
+ const s = fieldsOf(o.state, `${where}.state`, ['offset'], '{ "offset" }: where a public key (32 bytes) starts in the script’s state');
201
+ const last = SCRIPT_BOUNDS.maxStateLen - 32;
202
+ if (typeof s.offset !== 'number' || !Number.isInteger(s.offset) || s.offset < 0 || s.offset > last)
203
+ fail(`${where}.state.offset is a whole number from 0 to ${group(last)}: where a public key (32 bytes) starts in the script’s state.`);
204
+ wallet = { state: { offset: s.offset } };
205
+ }
206
+ const key = typeof wallet === 'string' ? wallet : `state:${wallet.state.offset}`;
207
+ if (seen.has(key))
208
+ fail(`${where} is named twice.`);
209
+ seen.add(key);
210
+ out.push(wallet);
211
+ });
212
+ return out;
213
+ }
214
+ /** `holdings`, held to the schema beside the flags it depends on. */
215
+ function holdingsOf(v, swapAccounts, payouts) {
216
+ const h = fieldsOf(v, 'holdings', ['minBalance', 'maxBalance', 'hookData', 'payTo', 'perStep'], '{ "minBalance", "maxBalance", "hookData", "payTo", "perStep" }');
217
+ if (h.minBalance === undefined)
218
+ fail('holdings.minBalance is required: the least balance that counts, base units.');
219
+ const minBalance = amountOf(h.minBalance, 'holdings.minBalance', 1n);
220
+ const maxBalance = h.maxBalance === undefined || h.maxBalance === null ? null : amountOf(h.maxBalance, 'holdings.maxBalance', 1n);
221
+ if (typeof minBalance === 'string' && typeof maxBalance === 'string' && BigInt(maxBalance) < BigInt(minBalance))
222
+ fail('holdings.maxBalance is at least holdings.minBalance.');
223
+ const hookData = hookDataOf(h.hookData, 'holdings.hookData');
224
+ if (typeof h.payTo !== 'string' || !KEEPER_PAY_TO.includes(h.payTo))
225
+ fail(`holdings.payTo is one of ${KEEPER_PAY_TO.join(', ')}: where each holding is paid.`);
226
+ const payTo = h.payTo;
227
+ // The companion pays a payout answer's holding to its owner itself (`payoutAccounts`): the step brings the owner, or the holding alone to be measured.
228
+ if (payouts && payTo === 'ownerQuoteHolding')
229
+ fail('with payouts the companion pays each holding’s owner itself: holdings.payTo is owner (or none for a step that only measures).');
230
+ const p = fieldsOf(h.perStep, 'holdings.perStep', ['withSwapAccounts', 'withoutSwapAccounts'], '{ "withSwapAccounts", "withoutSwapAccounts" }: holdings a step brings');
231
+ const perStep = { withSwapAccounts: perStepCount(p.withSwapAccounts, 'holdings.perStep.withSwapAccounts'), withoutSwapAccounts: perStepCount(p.withoutSwapAccounts, 'holdings.perStep.withoutSwapAccounts') };
232
+ if (swapAccounts && perStep.withSwapAccounts === null)
233
+ fail('holdings.perStep.withSwapAccounts is required while swapAccounts is true.');
234
+ if (!swapAccounts && perStep.withoutSwapAccounts === null)
235
+ fail('holdings.perStep.withoutSwapAccounts is required while swapAccounts is false.');
236
+ return { minBalance, maxBalance, hookData, payTo, perStep };
237
+ }
238
+ /** The declaration in a parsed JSON value, held to the schema and its bounds: its normal form, or why not, in one sentence. */
239
+ export function keeperDeclarationOf(value) {
240
+ try {
241
+ const root = fieldsOf(value, 'the file', ['version', 'holdings', 'swapAccounts', 'hookAccounts', 'payouts', 'roundSecs', 'wallets'], 'one object');
242
+ if (root.version !== KEEPER_VERSION)
243
+ fail(`version is ${KEEPER_VERSION}, the only one there is.`);
244
+ const wallets = walletsOf(root.wallets, 'wallets');
245
+ // A script that pays wallets alone brings no holdings.
246
+ const noHoldings = root.holdings === undefined || (root.holdings === null && wallets.length > 0);
247
+ if (noHoldings && wallets.length === 0)
248
+ fail('holdings is required: which holdings each step brings.');
249
+ const swapAccounts = flagOf(root.swapAccounts, 'swapAccounts', null);
250
+ const hookAccounts = flagOf(root.hookAccounts, 'hookAccounts', false);
251
+ const payouts = flagOf(root.payouts, 'payouts', false);
252
+ const holdings = noHoldings ? null : holdingsOf(root.holdings, swapAccounts, payouts);
253
+ const roundSecs = root.roundSecs === undefined || root.roundSecs === null ? null : secondsOf(root.roundSecs, 'roundSecs');
254
+ return { ok: true, declaration: { version: 1, holdings, swapAccounts, hookAccounts, payouts, roundSecs, ...(wallets.length > 0 ? { wallets } : {}) } };
255
+ }
256
+ catch (error) {
257
+ if (error instanceof KeeperProblem)
258
+ return { ok: false, problem: error.message };
259
+ throw error;
260
+ }
261
+ }
262
+ /** Bytes of a text in UTF-8 (a lone surrogate counts as its replacement character, 3). */
263
+ export function utf8Length(text) {
264
+ let n = 0;
265
+ for (const ch of text) {
266
+ const c = ch.codePointAt(0);
267
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
268
+ }
269
+ return n;
270
+ }
271
+ /** A `keeper.json` as submitted: within its size, JSON, then the schema (`keeperDeclarationOf`). */
272
+ export function parseKeeperDeclaration(text) {
273
+ const bytes = utf8Length(text);
274
+ if (bytes > KEEPER_LIMITS.bytes)
275
+ return { ok: false, problem: `${KEEPER_FILE} is at most ${group(KEEPER_LIMITS.bytes)} bytes; this one is ${group(bytes)}.` };
276
+ let value;
277
+ try {
278
+ value = JSON.parse(text);
279
+ }
280
+ catch {
281
+ return { ok: false, problem: `${KEEPER_FILE} is not JSON: check its quotes, commas and brackets (no comments, no trailing commas).` };
282
+ }
283
+ return keeperDeclarationOf(value);
284
+ }
285
+ /** The declaration in a canonical text (keys sorted, no spaces): what its hash is taken of, so the same declaration hashes the same however it was written. */
286
+ export function canonicalKeeperJson(d) {
287
+ const sorted = (v) => (Array.isArray(v) ? v.map(sorted) : isObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])])) : v);
288
+ return JSON.stringify(sorted(d));
289
+ }
290
+ /** Whether a file of a submission is the declaration. */
291
+ export const isKeeperFile = (path) => path === KEEPER_FILE;
292
+ // ---- in plain words -------------------------------------------------------------------------------------
293
+ /** A length of time as a word that goes before "epoch": 300 -> "5-minute", 3,600 -> "1-hour". */
294
+ function spanWord(secs) {
295
+ if (secs % 86_400 === 0)
296
+ return `${secs / 86_400}-day`;
297
+ if (secs % 3_600 === 0)
298
+ return `${secs / 3_600}-hour`;
299
+ if (secs % 60 === 0)
300
+ return `${secs / 60}-minute`;
301
+ return `${secs}-second`;
302
+ }
303
+ /** Base units as tokens: 20,000,000,000,000 -> "20M", 1,500,000,000 -> "1,500". */
304
+ function tokensWord(base, decimals) {
305
+ const one = 10n ** BigInt(decimals);
306
+ for (const [unit, suffix] of [[1000000000n, 'B'], [1000000n, 'M']]) {
307
+ if (base >= unit * one && base % ((unit * one) / 10n) === 0n)
308
+ return `${decimalWords(base / ((unit * one) / 10n), 1, 1)}${suffix}`;
309
+ }
310
+ return decimalWords(base, decimals, decimals);
311
+ }
312
+ /** "20M tokens", "1 token". */
313
+ const tokens = (base, decimals) => {
314
+ const w = tokensWord(base, decimals);
315
+ return `${w} ${w === '1' ? 'token' : 'tokens'}`;
316
+ };
317
+ const COMPARE_WORDS = { eq: 'equal to', ne: 'not equal to', lt: 'below', lte: 'at or below', gt: 'above', gte: 'at or above' };
318
+ function balanceWords(h, decimals) {
319
+ const low = typeof h.minBalance === 'string' ? (BigInt(h.minBalance) <= 1n ? null : `at least ${tokens(BigInt(h.minBalance), decimals)}`) : 'at least the balance its state sets';
320
+ let high = null;
321
+ if (typeof h.maxBalance === 'string') {
322
+ const above = BigInt(h.maxBalance) + 1n;
323
+ high = above % 10n ** BigInt(decimals) === 0n ? `under ${tokens(above, decimals)}` : `at most ${tokens(BigInt(h.maxBalance), decimals)}`;
324
+ }
325
+ else if (h.maxBalance)
326
+ high = 'at most the balance its state sets';
327
+ return low && high ? `with ${low} and ${high}` : low ? `with ${low}` : high ? `with ${high}` : '';
328
+ }
329
+ function filterWords(f, paid) {
330
+ if (!f)
331
+ return '';
332
+ const a = f.against;
333
+ if (a === 'poolPrice')
334
+ return `whose price in its hook data is ${COMPARE_WORDS[f.compare]} the pool’s price now`;
335
+ if ('const' in a)
336
+ return `whose hook data (bytes ${f.offset} to ${f.offset + f.width - 1}) is ${COMPARE_WORDS[f.compare]} ${group(BigInt(a.const))}`;
337
+ const epoch = `this ${typeof a.epoch.secs === 'number' ? `${spanWord(a.epoch.secs)} ` : ''}epoch`;
338
+ switch (f.compare) {
339
+ case 'ne':
340
+ return paid ? `not yet paid ${epoch}` : `not marked ${epoch}`;
341
+ case 'eq':
342
+ return `marked ${epoch}`;
343
+ case 'lt':
344
+ return `last marked before ${epoch}`;
345
+ case 'lte':
346
+ return `not marked after ${epoch}`;
347
+ case 'gt':
348
+ return `marked after ${epoch}`;
349
+ case 'gte':
350
+ return `marked ${epoch} or after`;
351
+ }
352
+ }
353
+ const PAY_WORDS = { owner: ', paid to their wallet', ownerQuoteHolding: ', paid to their SOL holding', none: '' };
354
+ /** How many holdings a step brings as declared: with the swap accounts when it carries them, else without; 0 without holdings (a script that pays wallets alone). */
355
+ export const keeperPerStep = (d) => (d.holdings ? ((d.swapAccounts ? d.holdings.perStep.withSwapAccounts : d.holdings.perStep.withoutSwapAccounts) ?? 1) : 0);
356
+ /** The wallets a declaration brings, in words: "Each step brings up to 2 wallets the script may pay SOL to, while its launcher allows wallet payouts."; '' without. */
357
+ function walletWords(d) {
358
+ const n = d.wallets?.length ?? 0;
359
+ return n === 0 ? '' : `Each step brings up to ${n} ${n === 1 ? 'wallet' : 'wallets'} the script may pay SOL to, while its launcher allows wallet payouts.`;
360
+ }
361
+ /**
362
+ * The declaration in one plain sentence, for the item's page, the reading and the operator: "Each
363
+ * step brings up to 3 holders with under 20M tokens not yet paid this 5-minute epoch, paid to their
364
+ * SOL holding." An epoch whose length the script's state sets is "this epoch". The wallets a step
365
+ * brings follow in a sentence of their own.
366
+ */
367
+ export function keeperWords(d, decimals = TOKEN_DECIMALS) {
368
+ const n = keeperPerStep(d);
369
+ const h = d.holdings;
370
+ if (!h)
371
+ return walletWords(d);
372
+ const noun = h.payTo === 'none' ? (n === 1 ? 'holding' : 'holdings') : n === 1 ? 'holder' : 'holders';
373
+ const parts = [`Each step brings up to ${n} ${noun}`, balanceWords(h, decimals), filterWords(h.hookData, h.payTo !== 'none')].filter((p) => p !== '');
374
+ return [`${parts.join(' ')}${PAY_WORDS[h.payTo]}.`, walletWords(d)].filter((p) => p !== '').join(' ');
375
+ }
376
+ /**
377
+ * The rest of the declaration, a sentence each, for the review and the operator: what else each
378
+ * step carries, and where a round's or an epoch's length comes from.
379
+ */
380
+ export function keeperDetails(d) {
381
+ const out = [];
382
+ const carries = [d.payouts ? 'the companion’s payout accounts' : null, d.swapAccounts ? 'the launch pool’s swap accounts' : d.hookAccounts ? 'the token’s hook accounts' : null].filter((x) => x !== null);
383
+ if (carries.length > 0)
384
+ out.push(`Each step also carries ${carries.join(' and ')}.`);
385
+ const other = d.holdings ? (d.swapAccounts ? d.holdings.perStep.withoutSwapAccounts : d.holdings.perStep.withSwapAccounts) : null;
386
+ if (other !== null)
387
+ out.push(`${d.swapAccounts ? 'Without' : 'With'} the swap accounts a step would bring up to ${other}.`);
388
+ if (d.wallets)
389
+ out.push(`The wallets (with the companion’s payout terms): ${d.wallets.map((w) => (typeof w === 'string' ? w : `the public key at byte ${w.state.offset} of the script’s state`)).join(', ')}.`);
390
+ const f = d.holdings?.hookData ?? null;
391
+ const field = (s) => `the ${s.width * 8}-bit number at byte ${s.offset} of the script’s state`;
392
+ if (f && typeof f.against === 'object' && 'epoch' in f.against) {
393
+ const e = f.against.epoch;
394
+ out.push(`An epoch is ${typeof e.secs === 'number' ? `${e.secs} seconds` : `${field(e.secs.state)} in seconds`}${e.fieldUnitSecs ? `; the hook data counts units of ${e.fieldUnitSecs} seconds` : ''}.`);
395
+ }
396
+ if (d.roundSecs !== null)
397
+ out.push(`A round of the keeper’s (what it says once a round: a wait, nobody to bring) is ${typeof d.roundSecs === 'number' ? `${d.roundSecs} seconds` : `${field(d.roundSecs.state)} in seconds`}.`);
398
+ return out;
399
+ }
package/dist/policy.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * `programs/tests/vectors/launch-fees.json`, which the Rust tests render from the reference the
7
7
  * programs are held to, and requires exact equality.
8
8
  */
9
- import type { CompanionSplitBps, CompanionTemplate, GameKindName, GameRequest, GameRolloverReason, JackpotRequest, LaunchRules, LaunchRulesInput, LotteryRequest, ScriptRequest, Side, SnowballRequest, SnowballTerms, StreakRequest } from './api.ts';
9
+ import type { CompanionSplitBps, CompanionTemplate, GameKindName, GameRequest, GameRolloverReason, JackpotRequest, LaunchRules, LaunchRulesInput, LossCoverRequest, LossCoverTerms, LotteryRequest, PayeeRequest, PayoutTermsRequest, ScriptRequest, Side, SnowballRequest, SnowballTerms, StreakRequest } from './api.ts';
10
10
  import type { StudioGameSettings } from './studio.ts';
11
11
  export declare const BPS = 10000n;
12
12
  export declare const WAD: bigint;
@@ -487,6 +487,13 @@ export declare const SCRIPT_INTERVAL_CHOICES_SECS: readonly [60, 300, 900, 3600,
487
487
  export declare function scriptInitDataLength(raw: string): number | null;
488
488
  /** 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. */
489
489
  export declare function scriptRequestOf(companion: unknown): ScriptRequest | null | 'malformed';
490
+ /** 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. */
491
+ export declare const PAYOUT_TERMS_BOUNDS: {
492
+ readonly minWindowSecs: 10;
493
+ readonly maxWindowSecs: 86400;
494
+ };
495
+ /** Whether payout terms are within the companion program's bounds (`create_script_v2` refuses them otherwise, `BadPayoutTerms`). */
496
+ export declare function payoutTermsWithin(t: PayoutTermsRequest): boolean;
490
497
  /**
491
498
  * Why a custom companion with this split can't run these rules at this fee, in the words the launch
492
499
  * form and the backend use (`companion_rules`); null when it can: the pot's part with the split adds
@@ -495,6 +502,106 @@ export declare function scriptRequestOf(companion: unknown): ScriptRequest | nul
495
502
  * backend's to check (`script_not_approved`).
496
503
  */
497
504
  export declare function scriptProblem(s: ScriptRequest, split: CompanionSplitBps, rules: Pick<LaunchRulesInput, 'holderFeeBuyBps' | 'holderFeeSellBps' | 'creatorLockDays'>, creatorFeeBps: number): string | null;
505
+ /** The most wallets that share a launcher's part (`set_payees`; the SDK's `MAX_PAYEES`). */
506
+ export declare const MAX_PAYEES = 10;
507
+ /** 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. */
508
+ export declare function payeesRequestOf(companion: unknown): PayeeRequest[] | null | 'malformed';
509
+ /** What the payees leave the launcher of their part, basis points of it (10,000 with none). */
510
+ export declare const payeesLeaveBps: (payees: readonly Pick<PayeeRequest, "bps">[]) => number;
511
+ /**
512
+ * Why `set_payees` would refuse these payees (`BadPayees`), in the words the launch form and the
513
+ * backend use (`companion_rules`); null when it would take them: 1 to 10 wallets, each named once,
514
+ * each 0.01% to 100% of the launcher's part and 100% in all at most; none the default key, the
515
+ * launcher (`beneficiary`) or one of `reserved` (the companion and its creator address, which the
516
+ * backend derives for the mint). A split with no part for the launcher has nothing to share.
517
+ */
518
+ export declare function payeesProblem(payees: readonly PayeeRequest[], split: Pick<CompanionSplitBps, 'beneficiaryBps'>, beneficiary: string, reserved?: readonly string[]): string | null;
519
+ /**
520
+ * Why a request's `allowWalletPayouts` can't be taken (`companion_rules`); null when it can, or it
521
+ * is off: only a custom hook's script with payout terms (`script.payout`, `create_script_v2`) may
522
+ * pay wallets, never Lossback's (its script pays holders under water, and nothing else).
523
+ */
524
+ export declare function walletPayoutsProblem(allow: boolean, script: Pick<ScriptRequest, 'payout'> | null): string | null;
525
+ /**
526
+ * The bounds a Lossback coin's terms keep within, as the site offers them: the programs' own
527
+ * (`loss_cover_script::CoverTerms::valid`, the companion's `PayoutTermsArgs::valid`) and the
528
+ * policy's tighter ones (the step at least 10 s and at most a quarter of the round, a holding of
529
+ * at most 1% of the supply, a loss of at least 0.0001 SOL, a wait of at most a day). The keeper's
530
+ * batches (`batchPaying`, `batchMeasuring`) are what one step carries within the runtime's
531
+ * instruction trace, measured by the programs' `loss_cover_vectors` (a measuring step carries one
532
+ * fewer on a coin's first step, when the creator's holding is made on the way).
533
+ */
534
+ export declare const LOSS_COVER_LIMITS: {
535
+ readonly minRoundSecs: 60;
536
+ readonly maxRoundSecs: 86400;
537
+ readonly minStepSecs: 10;
538
+ readonly maxStepSecs: 3600;
539
+ /** The step is at most this part of the round: a round has at least four steps. */
540
+ readonly stepsPerRoundMin: 4;
541
+ readonly minBps: 100;
542
+ readonly maxBps: 10000;
543
+ readonly minCoverBps: 1000;
544
+ readonly maxCoverBps: 10000;
545
+ /** The least holding that counts, at most this part of the supply (basis points). */
546
+ readonly maxMinHoldingBps: 100;
547
+ readonly minMinLossLamports: 100000n;
548
+ readonly maxMinAgeSecs: 86400;
549
+ readonly maxMinPotLamports: 100000000000n;
550
+ readonly batchPaying: 7;
551
+ readonly batchMeasuring: 13;
552
+ };
553
+ /**
554
+ * The terms the site launches a Lossback coin with: rounds of 5 minutes stepped every 15 seconds,
555
+ * the whole pot available to each step, each holding and each round, the whole loss covered when
556
+ * the pot can, a holding of 100,000 tokens (6 decimals: base units) and a loss of 0.001 SOL at
557
+ * least, a minute after its last buy (the owner, 2026-10-12; the first coin, LIFEVEST, was made with
558
+ * half an hour, fixed in its script state), a round only once the pot holds 1 SOL, the hook's whole
559
+ * fee row to the pot (the launcher's row 0: `potBps` is 10,000 less the launcher's part). The
560
+ * launcher sets `roundSecs`, `minPotLamports`, `coverBps` and `minHolding` (`LossCoverRequest`);
561
+ * the rest is fixed here. There is no cooldown: a holding is measured or paid once a round.
562
+ */
563
+ /** A holding's wait in words: "45 seconds", "1 minute", "30 minutes". */
564
+ export declare function ageWords(secs: number): string;
565
+ export declare const LOSS_COVER_DEFAULTS: {
566
+ readonly potBps: 10000;
567
+ readonly roundSecs: 300;
568
+ readonly stepSecs: 15;
569
+ readonly roundBps: 10000;
570
+ readonly stepBps: 10000;
571
+ readonly holdingBps: 10000;
572
+ readonly coverBps: 10000;
573
+ readonly minHolding: "100000000000";
574
+ readonly minLossLamports: "1000000";
575
+ readonly minAgeSecs: 60;
576
+ readonly minPotLamports: "1000000000";
577
+ };
578
+ /** The round lengths the launch form offers (seconds): 5, 10, 30 and 60 minutes. */
579
+ export declare const LOSS_COVER_ROUND_CHOICES_SECS: readonly [300, 600, 1800, 3600];
580
+ /** The cover shares the launch form offers: the whole loss, half, a quarter. */
581
+ export declare const LOSS_COVER_COVER_CHOICES_BPS: readonly [10000, 5000, 2500];
582
+ /** The minimum pots the launch form offers, lamports: none, 0.5, 1, 5 SOL. */
583
+ export declare const LOSS_COVER_MIN_POT_CHOICES_LAMPORTS: readonly ["0", "500000000", "1000000000", "5000000000"];
584
+ /** 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. */
585
+ export declare function lossCoverRequestOf(companion: unknown): LossCoverRequest | null | 'malformed';
586
+ /** The full terms a request launches with: what it names, the rest from `LOSS_COVER_DEFAULTS`. */
587
+ export declare function lossCoverTermsOf(s: LossCoverRequest): LossCoverTerms;
588
+ /** 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). */
589
+ export declare const lossCoverPotBps: (split: CompanionSplitBps) => number;
590
+ /**
591
+ * Why a Lossback coin with these terms can't launch with this split on these rules at this fee, in
592
+ * the words the launch form and the backend use (`companion_rules`); null when it can: a creator
593
+ * fee to fill the pot, no other token rules (it launches from Bordrless's Loss cover config, which
594
+ * has none, and runs its own hook in the kit's place), nothing bought back and no holders' part (the
595
+ * pot and the launcher only), a part for the pot, and the terms within `LOSS_COVER_LIMITS` (the
596
+ * programs refuse anything outside their own bounds at the setup's dry run). `supply` is the
597
+ * launch's, for the least holding's cap.
598
+ */
599
+ export declare function lossCoverProblem(s: LossCoverRequest, split: CompanionSplitBps, rules: LaunchRulesInput, creatorFeeBps: number, supply?: bigint): string | null;
600
+ /**
601
+ * The review step's sentence for a Lossback coin, in numbers: what it does, never that it protects
602
+ * anyone (docs/loss-cover.md §5).
603
+ */
604
+ export declare function lossCoverSentence(s: LossCoverRequest, decimals?: number): string;
498
605
  /** The template whose split this is, or null for any other split. */
499
606
  export declare function companionTemplateOf(split: CompanionSplitBps): CompanionTemplate | null;
500
607
  /** Kit module bits (§4.2). */