@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/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
|
+
}
|