@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/api.d.ts +91 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/keeper.d.ts +139 -0
- package/dist/keeper.js +399 -0
- package/dist/policy.d.ts +108 -1
- package/dist/policy.js +235 -1
- 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 +3 -1
- package/dist/studio.js +3 -1
- package/dist/workshop.d.ts +34 -6
- package/dist/workshop.js +8 -1
- package/package.json +1 -1
package/dist/rules.js
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { TOKEN_DECIMALS, TOKEN_SUPPLY } from "./policy.js";
|
|
2
|
+
import { TOKEN_HOOK_FLAGS } from "./programs.js";
|
|
3
|
+
// ---- the language ----------------------------------------------------------------------------------
|
|
4
|
+
export const RULES_VERSION = 1;
|
|
5
|
+
/** The header, then each rule, then each atom slot (§1.2). */
|
|
6
|
+
export const RULES_HEADER_BYTES = 8;
|
|
7
|
+
export const RULE_BYTES = 48;
|
|
8
|
+
export const ATOM_BYTES = 10;
|
|
9
|
+
export const RULE_ATOM_SLOTS = 4;
|
|
10
|
+
export const RULES_LIMITS = {
|
|
11
|
+
/** Rules in a set. */
|
|
12
|
+
rules: 6,
|
|
13
|
+
/** `when` atoms a rule may have; `unless` atoms. */
|
|
14
|
+
when: 3,
|
|
15
|
+
unless: 1,
|
|
16
|
+
/** A cut, basis points of the amount; the cuts of one side summed. */
|
|
17
|
+
cutBps: 1_500,
|
|
18
|
+
sideCutBps: 1_500,
|
|
19
|
+
/** `entry_cap_bps` (§1.2): checked by `bind`, applied nowhere (a buy's entry is what it paid). */
|
|
20
|
+
entryCapBps: 5_000,
|
|
21
|
+
/** A set's bytes: `8 + 48 * 6`. */
|
|
22
|
+
bytes: 296,
|
|
23
|
+
/** Composed hooks one wallet may list in a day (the Workshop's allowance). */
|
|
24
|
+
perDay: 6,
|
|
25
|
+
/** Literals, by unit (§1.2). A price is any u64. */
|
|
26
|
+
literal: {
|
|
27
|
+
bps: 10000n,
|
|
28
|
+
seconds: 31536000n,
|
|
29
|
+
lamports: 10000000000000n,
|
|
30
|
+
baseUnits: TOKEN_SUPPLY,
|
|
31
|
+
count: 65535n,
|
|
32
|
+
flag: 1n,
|
|
33
|
+
price: (1n << 64n) - 1n,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export const RULE_SIDES = ['sell', 'buy', 'send', 'receive', 'burn'];
|
|
37
|
+
/** `on`'s bits. */
|
|
38
|
+
export const RULE_SIDE_BITS = { sell: 1, buy: 2, send: 4, receive: 8, burn: 16 };
|
|
39
|
+
/** The values a rule can see (§1.1), by id. */
|
|
40
|
+
export const RULE_VALUE_IDS = {
|
|
41
|
+
amount: 1,
|
|
42
|
+
amountShare: 2,
|
|
43
|
+
balanceBefore: 3,
|
|
44
|
+
balanceAfter: 4,
|
|
45
|
+
shareAfter: 5,
|
|
46
|
+
tradePrice: 6,
|
|
47
|
+
spotPrice: 7,
|
|
48
|
+
gain: 8,
|
|
49
|
+
loss: 9,
|
|
50
|
+
valueBefore: 10,
|
|
51
|
+
valueAfter: 11,
|
|
52
|
+
sinceReceived: 12,
|
|
53
|
+
sinceSent: 13,
|
|
54
|
+
age: 14,
|
|
55
|
+
firstAfterLaunch: 15,
|
|
56
|
+
sinceLaunch: 16,
|
|
57
|
+
counter: 17,
|
|
58
|
+
graduated: 18,
|
|
59
|
+
hasEntry: 19,
|
|
60
|
+
flag0: 32,
|
|
61
|
+
flag1: 33,
|
|
62
|
+
flag2: 34,
|
|
63
|
+
flag3: 35,
|
|
64
|
+
};
|
|
65
|
+
/** Each value's unit, which bounds its literal. */
|
|
66
|
+
export const RULE_VALUE_UNITS = {
|
|
67
|
+
amount: 'baseUnits',
|
|
68
|
+
amountShare: 'bps',
|
|
69
|
+
balanceBefore: 'baseUnits',
|
|
70
|
+
balanceAfter: 'baseUnits',
|
|
71
|
+
shareAfter: 'bps',
|
|
72
|
+
tradePrice: 'price',
|
|
73
|
+
spotPrice: 'price',
|
|
74
|
+
gain: 'bps',
|
|
75
|
+
loss: 'bps',
|
|
76
|
+
valueBefore: 'lamports',
|
|
77
|
+
valueAfter: 'lamports',
|
|
78
|
+
sinceReceived: 'seconds',
|
|
79
|
+
sinceSent: 'seconds',
|
|
80
|
+
age: 'seconds',
|
|
81
|
+
firstAfterLaunch: 'seconds',
|
|
82
|
+
sinceLaunch: 'seconds',
|
|
83
|
+
counter: 'count',
|
|
84
|
+
graduated: 'flag',
|
|
85
|
+
hasEntry: 'flag',
|
|
86
|
+
flag0: 'flag',
|
|
87
|
+
flag1: 'flag',
|
|
88
|
+
flag2: 'flag',
|
|
89
|
+
flag3: 'flag',
|
|
90
|
+
};
|
|
91
|
+
/** The header's `keeps` bits. */
|
|
92
|
+
export const RULE_KEEP_BITS = { entry: 1, times: 2, count: 4 };
|
|
93
|
+
/** What each value needs the engine to keep (§2.3); null for one computed from the operation, the pool or the clock alone. */
|
|
94
|
+
export const RULE_VALUE_KEEPS = {
|
|
95
|
+
amount: null,
|
|
96
|
+
amountShare: null,
|
|
97
|
+
balanceBefore: null,
|
|
98
|
+
balanceAfter: null,
|
|
99
|
+
shareAfter: null,
|
|
100
|
+
tradePrice: null,
|
|
101
|
+
spotPrice: null,
|
|
102
|
+
gain: 'entry',
|
|
103
|
+
loss: 'entry',
|
|
104
|
+
valueBefore: null,
|
|
105
|
+
valueAfter: null,
|
|
106
|
+
sinceReceived: 'times',
|
|
107
|
+
sinceSent: 'times',
|
|
108
|
+
age: 'times',
|
|
109
|
+
firstAfterLaunch: 'times',
|
|
110
|
+
sinceLaunch: null,
|
|
111
|
+
counter: 'count',
|
|
112
|
+
graduated: null,
|
|
113
|
+
hasEntry: 'entry',
|
|
114
|
+
flag0: 'count',
|
|
115
|
+
flag1: 'count',
|
|
116
|
+
flag2: 'count',
|
|
117
|
+
flag3: 'count',
|
|
118
|
+
};
|
|
119
|
+
export const RULE_OP_IDS = { lt: 1, le: 2, gt: 3, ge: 4, eq: 5, ne: 6 };
|
|
120
|
+
export const RULE_ACTION_IDS = { cut: 1, refuse: 2, count: 3, flag: 4 };
|
|
121
|
+
export const REFUSE_REASON_IDS = { cooling: 1, cap: 2, locked: 3, rule: 4 };
|
|
122
|
+
export const COUNT_ARG_IDS = { add: 1, reset: 2 };
|
|
123
|
+
/** A FLAG's `arg`: bits 0..1 which flag, bit 4 set (1) or clear (0). */
|
|
124
|
+
export const FLAG_SET_BIT = 16;
|
|
125
|
+
export const RULE_DESTINATION_IDS = { none: 0, burn: 1, creator: 2, named: 3 };
|
|
126
|
+
const byId = (table) => new Map(Object.entries(table).map(([k, v]) => [v, k]));
|
|
127
|
+
const VALUE_BY_ID = byId(RULE_VALUE_IDS);
|
|
128
|
+
const OP_BY_ID = byId(RULE_OP_IDS);
|
|
129
|
+
const ACTION_BY_ID = byId(RULE_ACTION_IDS);
|
|
130
|
+
const REASON_BY_ID = byId(REFUSE_REASON_IDS);
|
|
131
|
+
const COUNT_BY_ID = byId(COUNT_ARG_IDS);
|
|
132
|
+
const DESTINATION_BY_ID = byId(RULE_DESTINATION_IDS);
|
|
133
|
+
const SIDE_MASK_ALL = 31;
|
|
134
|
+
const KEEPS_MASK_ALL = 7;
|
|
135
|
+
export const RULES_ERROR_TEXT = {
|
|
136
|
+
length: 'A rule set is 8 bytes and then 48 for each rule.',
|
|
137
|
+
version: 'The rule set’s version is not 1.',
|
|
138
|
+
rule_count: 'A rule set has 1 to 6 rules.',
|
|
139
|
+
keeps: 'The header’s keeps bits are entry, times and count only.',
|
|
140
|
+
destination: 'The destination is none, burn, the creator or a named wallet.',
|
|
141
|
+
side: 'A rule’s sides are some of sell, buy, send, receive and burn, at least one.',
|
|
142
|
+
value: 'A condition reads one of the values the engine computes.',
|
|
143
|
+
op: 'A condition compares with one of lt, le, gt, ge, eq and ne.',
|
|
144
|
+
action: 'A rule’s action is a cut, a refusal, a count or a mark.',
|
|
145
|
+
arg: 'A refusal’s reason is cooling, cap, locked or rule; a count adds or resets; a mark names a flag 0 to 3.',
|
|
146
|
+
when_count: 'A rule has at most 3 conditions.',
|
|
147
|
+
unless_count: 'A rule has at most one unless.',
|
|
148
|
+
slot: 'A rule’s atom slots hold its conditions and its unless, in order, and nothing else.',
|
|
149
|
+
literal: 'A threshold is within its unit’s bound: at most 10,000 basis points, 365 days, 10,000 SOL, the supply, or a count of 65,535.',
|
|
150
|
+
cut_bps: 'A cut is 1 to 1,500 basis points; other actions carry none.',
|
|
151
|
+
cut_on_burn: 'A cut cannot be taken on a burn.',
|
|
152
|
+
cut_destination: 'A cut needs a destination: burn, the creator or a named wallet.',
|
|
153
|
+
side_total: 'The cuts on one side add up to at most 1,500 basis points.',
|
|
154
|
+
keeps_entry: 'A condition on gain, loss or the entry price needs the set to keep an entry price.',
|
|
155
|
+
keeps_times: 'A condition on a time since receiving, sending, the first tokens or the holding’s age needs the set to keep times.',
|
|
156
|
+
keeps_count: 'A count, a mark or a condition on one needs the set to keep a counter.',
|
|
157
|
+
entry_cap: 'The entry cap is 1 to 5,000 basis points and needs the set to keep an entry price.',
|
|
158
|
+
refuse_when: 'A refusal needs at least one condition.',
|
|
159
|
+
refuse_marks: 'A refusal of a sell, a send or a burn must turn on time, amount, balance, share, price or value, not on a mark or the count alone.',
|
|
160
|
+
destination_wallet: 'A named destination is a wallet on the curve, not the default key.',
|
|
161
|
+
config_hook: 'The config does not name the rules engine as its custom hook.',
|
|
162
|
+
config_flags: 'The config’s hook flags are not the ones the rules need.',
|
|
163
|
+
config_kit_rules: 'A composed config carries no kit rules.',
|
|
164
|
+
config_creator: 'Only the config’s creator binds rules to it.',
|
|
165
|
+
};
|
|
166
|
+
export const RULES_ERROR_CODES = Object.keys(RULES_ERROR_TEXT);
|
|
167
|
+
export class RulesError extends Error {
|
|
168
|
+
code;
|
|
169
|
+
rule;
|
|
170
|
+
constructor(code, rule = null) {
|
|
171
|
+
super(rule === null ? RULES_ERROR_TEXT[code] : `Rule ${rule + 1}: ${RULES_ERROR_TEXT[code]}`);
|
|
172
|
+
this.name = 'RulesError';
|
|
173
|
+
this.code = code;
|
|
174
|
+
this.rule = rule;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const problem = (code, rule = null) => ({ code, rule, text: rule === null ? RULES_ERROR_TEXT[code] : `Rule ${rule + 1}: ${RULES_ERROR_TEXT[code]}` });
|
|
178
|
+
// ---- the bytes ------------------------------------------------------------------------------------------
|
|
179
|
+
export const rulesBytesLength = (ruleCount) => RULES_HEADER_BYTES + RULE_BYTES * ruleCount;
|
|
180
|
+
/** `on` as its mask. */
|
|
181
|
+
export const sideMask = (on) => on.reduce((m, s) => m | (RULE_SIDE_BITS[s] ?? 0), 0);
|
|
182
|
+
/** A mask as its sides, in the doc's order; unknown bits are dropped (the validator refuses them). */
|
|
183
|
+
export const sidesOf = (mask) => RULE_SIDES.filter((s) => (mask & RULE_SIDE_BITS[s]) !== 0);
|
|
184
|
+
export const keepsMask = (k) => (k.entry ? RULE_KEEP_BITS.entry : 0) | (k.times ? RULE_KEEP_BITS.times : 0) | (k.count ? RULE_KEEP_BITS.count : 0);
|
|
185
|
+
/** An action's `(action, arg, bps)` bytes. */
|
|
186
|
+
export function actionBytes(a) {
|
|
187
|
+
switch (a.kind) {
|
|
188
|
+
case 'cut':
|
|
189
|
+
return { action: RULE_ACTION_IDS.cut, arg: 0, bps: a.bps };
|
|
190
|
+
case 'refuse':
|
|
191
|
+
return { action: RULE_ACTION_IDS.refuse, arg: REFUSE_REASON_IDS[a.reason] ?? 0, bps: 0 };
|
|
192
|
+
case 'count':
|
|
193
|
+
return { action: RULE_ACTION_IDS.count, arg: COUNT_ARG_IDS[a.what] ?? 0, bps: 0 };
|
|
194
|
+
case 'flag':
|
|
195
|
+
return { action: RULE_ACTION_IDS.flag, arg: (a.flag & 3) | (a.set ? FLAG_SET_BIT : 0), bps: 0 };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const U64_MAX = (1n << 64n) - 1n;
|
|
199
|
+
/**
|
|
200
|
+
* The set's bytes (§1.2): the header, then each rule, its `when` atoms in the first slots and its
|
|
201
|
+
* `unless` in the next, the rest zero. Encodes what it is given; `validateRules` says whether the
|
|
202
|
+
* engine will take it. Throws `RulesError` only for what has no encoding (more atoms than slots, a
|
|
203
|
+
* literal past u64, more than 255 rules).
|
|
204
|
+
*/
|
|
205
|
+
export function encodeRules(set) {
|
|
206
|
+
if (set.rules.length > 255)
|
|
207
|
+
throw new RulesError('rule_count');
|
|
208
|
+
const out = new Uint8Array(rulesBytesLength(set.rules.length));
|
|
209
|
+
const view = new DataView(out.buffer);
|
|
210
|
+
out[0] = set.version & 255;
|
|
211
|
+
out[1] = keepsMask(set.keeps);
|
|
212
|
+
view.setUint16(2, set.entryCapBps & 0xffff, true);
|
|
213
|
+
out[4] = set.rules.length;
|
|
214
|
+
out[5] = RULE_DESTINATION_IDS[set.destination] ?? 0;
|
|
215
|
+
view.setUint16(6, 0, true);
|
|
216
|
+
set.rules.forEach((r, i) => {
|
|
217
|
+
const at = RULES_HEADER_BYTES + RULE_BYTES * i;
|
|
218
|
+
const atoms = [...r.when, ...(r.unless ? [r.unless] : [])];
|
|
219
|
+
if (atoms.length > RULE_ATOM_SLOTS)
|
|
220
|
+
throw new RulesError('slot', i);
|
|
221
|
+
out[at] = sideMask(r.on);
|
|
222
|
+
out[at + 1] = r.when.length;
|
|
223
|
+
out[at + 2] = r.unless ? 1 : 0;
|
|
224
|
+
out[at + 3] = r.stop ? 1 : 0;
|
|
225
|
+
const a = actionBytes(r.then);
|
|
226
|
+
out[at + 4] = a.action;
|
|
227
|
+
out[at + 5] = a.arg;
|
|
228
|
+
view.setUint16(at + 6, a.bps & 0xffff, true);
|
|
229
|
+
atoms.forEach((atom, j) => {
|
|
230
|
+
const slot = at + 8 + ATOM_BYTES * j;
|
|
231
|
+
if (atom.literal < 0n || atom.literal > U64_MAX)
|
|
232
|
+
throw new RulesError('literal', i);
|
|
233
|
+
out[slot] = RULE_VALUE_IDS[atom.value] ?? 0;
|
|
234
|
+
out[slot + 1] = RULE_OP_IDS[atom.op] ?? 0;
|
|
235
|
+
view.setBigUint64(slot + 2, atom.literal, true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* A set from its bytes, refusing with the engine's own code what the layout cannot hold: the
|
|
242
|
+
* length, the version, the count, unknown keeps bits, an unknown destination, side, value, op,
|
|
243
|
+
* action or arg, counts past 3 and 1, a filled slot beyond them, a `bps` on an action that is not a
|
|
244
|
+
* cut. The bounds that need the whole set (`validateRules`) are checked after; `checkRulesBytes`
|
|
245
|
+
* does both.
|
|
246
|
+
*/
|
|
247
|
+
export function decodeRules(bytes) {
|
|
248
|
+
if (bytes.length < RULES_HEADER_BYTES)
|
|
249
|
+
throw new RulesError('length');
|
|
250
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
251
|
+
const version = bytes[0];
|
|
252
|
+
if (version !== RULES_VERSION)
|
|
253
|
+
throw new RulesError('version');
|
|
254
|
+
const keeps = bytes[1];
|
|
255
|
+
if ((keeps & ~KEEPS_MASK_ALL) !== 0)
|
|
256
|
+
throw new RulesError('keeps');
|
|
257
|
+
const entryCapBps = view.getUint16(2, true);
|
|
258
|
+
const count = bytes[4];
|
|
259
|
+
if (count < 1 || count > RULES_LIMITS.rules)
|
|
260
|
+
throw new RulesError('rule_count');
|
|
261
|
+
const destination = DESTINATION_BY_ID.get(bytes[5]);
|
|
262
|
+
if (destination === undefined)
|
|
263
|
+
throw new RulesError('destination');
|
|
264
|
+
if (bytes.length !== rulesBytesLength(count))
|
|
265
|
+
throw new RulesError('length');
|
|
266
|
+
const rules = [];
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
const at = RULES_HEADER_BYTES + RULE_BYTES * i;
|
|
269
|
+
const mask = bytes[at];
|
|
270
|
+
if (mask === 0 || (mask & ~SIDE_MASK_ALL) !== 0)
|
|
271
|
+
throw new RulesError('side', i);
|
|
272
|
+
const whenN = bytes[at + 1];
|
|
273
|
+
const unlessN = bytes[at + 2];
|
|
274
|
+
if (whenN > RULES_LIMITS.when)
|
|
275
|
+
throw new RulesError('when_count', i);
|
|
276
|
+
if (unlessN > RULES_LIMITS.unless)
|
|
277
|
+
throw new RulesError('unless_count', i);
|
|
278
|
+
const stop = bytes[at + 3] !== 0;
|
|
279
|
+
const actionKind = ACTION_BY_ID.get(bytes[at + 4]);
|
|
280
|
+
if (actionKind === undefined)
|
|
281
|
+
throw new RulesError('action', i);
|
|
282
|
+
const arg = bytes[at + 5];
|
|
283
|
+
const bps = view.getUint16(at + 6, true);
|
|
284
|
+
let then;
|
|
285
|
+
if (actionKind === 'cut') {
|
|
286
|
+
if (arg !== 0)
|
|
287
|
+
throw new RulesError('arg', i);
|
|
288
|
+
then = { kind: 'cut', bps };
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
if (bps !== 0)
|
|
292
|
+
throw new RulesError('cut_bps', i);
|
|
293
|
+
if (actionKind === 'refuse') {
|
|
294
|
+
const reason = REASON_BY_ID.get(arg);
|
|
295
|
+
if (reason === undefined)
|
|
296
|
+
throw new RulesError('arg', i);
|
|
297
|
+
then = { kind: 'refuse', reason };
|
|
298
|
+
}
|
|
299
|
+
else if (actionKind === 'count') {
|
|
300
|
+
const what = COUNT_BY_ID.get(arg);
|
|
301
|
+
if (what === undefined)
|
|
302
|
+
throw new RulesError('arg', i);
|
|
303
|
+
then = { kind: 'count', what };
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
if ((arg & ~(3 | FLAG_SET_BIT)) !== 0)
|
|
307
|
+
throw new RulesError('arg', i);
|
|
308
|
+
then = { kind: 'flag', flag: (arg & 3), set: (arg & FLAG_SET_BIT) !== 0 };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const atoms = [];
|
|
312
|
+
for (let j = 0; j < RULE_ATOM_SLOTS; j++) {
|
|
313
|
+
const slot = at + 8 + ATOM_BYTES * j;
|
|
314
|
+
const valueId = bytes[slot];
|
|
315
|
+
const opId = bytes[slot + 1];
|
|
316
|
+
const literal = view.getBigUint64(slot + 2, true);
|
|
317
|
+
if (j >= whenN + unlessN) {
|
|
318
|
+
if (valueId !== 0 || opId !== 0 || literal !== 0n)
|
|
319
|
+
throw new RulesError('slot', i);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const value = VALUE_BY_ID.get(valueId);
|
|
323
|
+
if (value === undefined)
|
|
324
|
+
throw new RulesError('value', i);
|
|
325
|
+
const op = OP_BY_ID.get(opId);
|
|
326
|
+
if (op === undefined)
|
|
327
|
+
throw new RulesError('op', i);
|
|
328
|
+
atoms.push({ value, op, literal });
|
|
329
|
+
}
|
|
330
|
+
rules.push({ on: sidesOf(mask), when: atoms.slice(0, whenN), unless: unlessN === 1 ? atoms[whenN] : null, then, stop });
|
|
331
|
+
}
|
|
332
|
+
return { version, keeps: { entry: (keeps & RULE_KEEP_BITS.entry) !== 0, times: (keeps & RULE_KEEP_BITS.times) !== 0, count: (keeps & RULE_KEEP_BITS.count) !== 0 }, entryCapBps, destination, rules };
|
|
333
|
+
}
|
|
334
|
+
export const rulesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
335
|
+
export function rulesFromHex(hex) {
|
|
336
|
+
const clean = hex.trim().replace(/^0x/i, '');
|
|
337
|
+
if (clean.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(clean))
|
|
338
|
+
throw new RulesError('length');
|
|
339
|
+
const out = new Uint8Array(clean.length / 2);
|
|
340
|
+
for (let i = 0; i < out.length; i++)
|
|
341
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
// ---- the validator --------------------------------------------------------------------------------------
|
|
345
|
+
/** The bound a value's literal keeps to. */
|
|
346
|
+
export const literalBound = (value) => RULES_LIMITS.literal[RULE_VALUE_UNITS[value]];
|
|
347
|
+
/** Whether a value turns on a holding's own marks (a flag or the counter): never enough for a refusal of a sell, a send or a burn (§2.6). */
|
|
348
|
+
export const isMarkValue = (value) => RULE_VALUE_KEEPS[value] === 'count';
|
|
349
|
+
const CUT_SIDES = ['sell', 'buy', 'send', 'receive'];
|
|
350
|
+
/**
|
|
351
|
+
* Every refusal of §2.6 that the bytes alone do not catch, in order, each once per rule: the codes
|
|
352
|
+
* the engine's `bind` answers with. Empty when the engine will take the set. `validateRules` reads
|
|
353
|
+
* a typed set; `checkRulesBytes` decodes first.
|
|
354
|
+
*/
|
|
355
|
+
export function validateRules(set) {
|
|
356
|
+
const out = [];
|
|
357
|
+
if (set.version !== RULES_VERSION)
|
|
358
|
+
out.push(problem('version'));
|
|
359
|
+
if (set.rules.length < 1 || set.rules.length > RULES_LIMITS.rules)
|
|
360
|
+
out.push(problem('rule_count'));
|
|
361
|
+
if (!(set.destination in RULE_DESTINATION_IDS))
|
|
362
|
+
out.push(problem('destination'));
|
|
363
|
+
if (!Number.isInteger(set.entryCapBps) || set.entryCapBps < 0 || set.entryCapBps > RULES_LIMITS.entryCapBps || (set.entryCapBps > 0 && !set.keeps.entry))
|
|
364
|
+
out.push(problem('entry_cap'));
|
|
365
|
+
const totals = { sell: 0, buy: 0, send: 0, receive: 0, burn: 0 };
|
|
366
|
+
set.rules.forEach((r, i) => {
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
const add = (code) => {
|
|
369
|
+
if (!seen.has(code)) {
|
|
370
|
+
seen.add(code);
|
|
371
|
+
out.push(problem(code, i));
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
if (r.on.length === 0 || r.on.some((s) => !(s in RULE_SIDE_BITS)) || new Set(r.on).size !== r.on.length)
|
|
375
|
+
add('side');
|
|
376
|
+
if (r.when.length > RULES_LIMITS.when)
|
|
377
|
+
add('when_count');
|
|
378
|
+
const atoms = [...r.when, ...(r.unless ? [r.unless] : [])];
|
|
379
|
+
for (const atom of atoms) {
|
|
380
|
+
if (!(atom.value in RULE_VALUE_IDS)) {
|
|
381
|
+
add('value');
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (!(atom.op in RULE_OP_IDS))
|
|
385
|
+
add('op');
|
|
386
|
+
if (typeof atom.literal !== 'bigint' || atom.literal < 0n || atom.literal > literalBound(atom.value))
|
|
387
|
+
add('literal');
|
|
388
|
+
const keep = RULE_VALUE_KEEPS[atom.value];
|
|
389
|
+
if (keep === 'entry' && !set.keeps.entry)
|
|
390
|
+
add('keeps_entry');
|
|
391
|
+
if (keep === 'times' && !set.keeps.times)
|
|
392
|
+
add('keeps_times');
|
|
393
|
+
if (keep === 'count' && !set.keeps.count)
|
|
394
|
+
add('keeps_count');
|
|
395
|
+
}
|
|
396
|
+
const a = r.then;
|
|
397
|
+
if (!a || !(a.kind in RULE_ACTION_IDS))
|
|
398
|
+
add('action');
|
|
399
|
+
else if (a.kind === 'cut') {
|
|
400
|
+
if (!Number.isInteger(a.bps) || a.bps < 1 || a.bps > RULES_LIMITS.cutBps)
|
|
401
|
+
add('cut_bps');
|
|
402
|
+
if (r.on.includes('burn'))
|
|
403
|
+
add('cut_on_burn');
|
|
404
|
+
if (set.destination === 'none')
|
|
405
|
+
add('cut_destination');
|
|
406
|
+
for (const s of r.on)
|
|
407
|
+
if (s in totals)
|
|
408
|
+
totals[s] += Number.isInteger(a.bps) && a.bps > 0 ? a.bps : 0;
|
|
409
|
+
}
|
|
410
|
+
else if (a.kind === 'refuse') {
|
|
411
|
+
if (!(a.reason in REFUSE_REASON_IDS))
|
|
412
|
+
add('arg');
|
|
413
|
+
if (r.when.length === 0)
|
|
414
|
+
add('refuse_when');
|
|
415
|
+
else if (r.on.some((s) => s === 'sell' || s === 'send' || s === 'burn') && r.when.every((atom) => atom.value in RULE_VALUE_IDS && isMarkValue(atom.value)))
|
|
416
|
+
add('refuse_marks');
|
|
417
|
+
}
|
|
418
|
+
else if (a.kind === 'count') {
|
|
419
|
+
if (!(a.what in COUNT_ARG_IDS))
|
|
420
|
+
add('arg');
|
|
421
|
+
if (!set.keeps.count)
|
|
422
|
+
add('keeps_count');
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
if (!Number.isInteger(a.flag) || a.flag < 0 || a.flag > 3)
|
|
426
|
+
add('arg');
|
|
427
|
+
if (!set.keeps.count)
|
|
428
|
+
add('keeps_count');
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
for (const s of CUT_SIDES)
|
|
432
|
+
if (totals[s] > RULES_LIMITS.sideCutBps)
|
|
433
|
+
out.push(problem('side_total'));
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
/** Decodes and validates: the problems, or the set. */
|
|
437
|
+
export function checkRulesBytes(bytes) {
|
|
438
|
+
let set;
|
|
439
|
+
try {
|
|
440
|
+
set = decodeRules(bytes);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
if (e instanceof RulesError)
|
|
444
|
+
return { set: null, problems: [problem(e.code, e.rule)] };
|
|
445
|
+
throw e;
|
|
446
|
+
}
|
|
447
|
+
return { set, problems: validateRules(set) };
|
|
448
|
+
}
|
|
449
|
+
/** The set, or the first refusal thrown (the builder's `build`). */
|
|
450
|
+
export function assertRules(set) {
|
|
451
|
+
const [first] = validateRules(set);
|
|
452
|
+
if (first)
|
|
453
|
+
throw new RulesError(first.code, first.rule);
|
|
454
|
+
return set;
|
|
455
|
+
}
|
|
456
|
+
// ---- the flags ------------------------------------------------------------------------------------------
|
|
457
|
+
/**
|
|
458
|
+
* The token hook flags a set needs (`flags_for`, §2): `BEFORE_TRANSFER` always;
|
|
459
|
+
* `TRANSFER_RETURNS_DELTA` with any cut; `BEFORE_BURN` with any rule on burns (so a burn rule runs
|
|
460
|
+
* even in a set that keeps nothing); `BEFORE_BURN | WRITES_HOOK_DATA` with any keeps bit, a count
|
|
461
|
+
* or a mark. The config must name exactly these (`config_flags`).
|
|
462
|
+
*/
|
|
463
|
+
export function rulesFlagsOf(set) {
|
|
464
|
+
let flags = TOKEN_HOOK_FLAGS.BEFORE_TRANSFER;
|
|
465
|
+
if (set.rules.some((r) => r.then.kind === 'cut'))
|
|
466
|
+
flags |= TOKEN_HOOK_FLAGS.TRANSFER_RETURNS_DELTA;
|
|
467
|
+
if (set.rules.some((r) => r.on.includes('burn')))
|
|
468
|
+
flags |= TOKEN_HOOK_FLAGS.BEFORE_BURN;
|
|
469
|
+
if (keepsMask(set.keeps) !== 0 || set.rules.some((r) => r.then.kind === 'count' || r.then.kind === 'flag'))
|
|
470
|
+
flags |= TOKEN_HOOK_FLAGS.BEFORE_BURN | TOKEN_HOOK_FLAGS.WRITES_HOOK_DATA;
|
|
471
|
+
return flags;
|
|
472
|
+
}
|
|
473
|
+
// ---- numbers in words ------------------------------------------------------------------------------------
|
|
474
|
+
const group = (whole) => {
|
|
475
|
+
const s = whole.toString();
|
|
476
|
+
let out = '';
|
|
477
|
+
for (let i = 0; i < s.length; i++) {
|
|
478
|
+
if (i > 0 && (s.length - i) % 3 === 0)
|
|
479
|
+
out += ',';
|
|
480
|
+
out += s[i];
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
};
|
|
484
|
+
/** A fixed-point value with `decimals`, rounded half up to `places`, trailing zeros trimmed, the whole part grouped. */
|
|
485
|
+
export function decimalWords(value, decimals, places) {
|
|
486
|
+
const drop = decimals - places;
|
|
487
|
+
const scaled = drop > 0 ? (value + 10n ** BigInt(drop) / 2n) / 10n ** BigInt(drop) : value * 10n ** BigInt(-drop);
|
|
488
|
+
const unit = 10n ** BigInt(places);
|
|
489
|
+
const whole = scaled / unit;
|
|
490
|
+
const frac = places > 0 ? (scaled % unit).toString().padStart(places, '0').replace(/0+$/, '') : '';
|
|
491
|
+
return frac ? `${group(whole)}.${frac}` : group(whole);
|
|
492
|
+
}
|
|
493
|
+
/** Basis points as a percent: 500 -> 5%, 25 -> 0.25%, 1,500 -> 15%. */
|
|
494
|
+
export const pctWords = (bps) => `${decimalWords(BigInt(bps), 2, 2)}%`;
|
|
495
|
+
/** Lamports as SOL, to three places: 6,500,000,000 -> 6.5. */
|
|
496
|
+
export const solWords = (lamports) => decimalWords(lamports, 9, 3);
|
|
497
|
+
/** Base units as whole tokens (to six places when not whole), with the ticker, or "tokens". */
|
|
498
|
+
export const tokenWords = (base, symbol) => `${decimalWords(base, TOKEN_DECIMALS, TOKEN_DECIMALS)} ${symbol?.trim() || 'tokens'}`;
|
|
499
|
+
/** A price (lamports per base unit times 10^12) as SOL per whole token: `p * 10^decimals / 10^21`. */
|
|
500
|
+
export const priceWords = (price) => decimalWords(price, 21 - TOKEN_DECIMALS, 9);
|
|
501
|
+
/** A length of time in a sentence: a minute, 10 minutes, an hour, 6 hours, a day, 30 days, 30 seconds. With `article` false, the noun alone for one unit: "the first minute", "the first day". */
|
|
502
|
+
export function durationWords(secs, article = true) {
|
|
503
|
+
const s = BigInt(secs);
|
|
504
|
+
const unit = (n, one, many) => (n === 1n ? (article ? `${one === 'hour' ? 'an' : 'a'} ${one}` : one) : `${group(n)} ${many}`);
|
|
505
|
+
if (s >= 86400n && s % 86400n === 0n)
|
|
506
|
+
return unit(s / 86400n, 'day', 'days');
|
|
507
|
+
if (s >= 3600n && s % 3600n === 0n)
|
|
508
|
+
return unit(s / 3600n, 'hour', 'hours');
|
|
509
|
+
if (s >= 60n && s % 60n === 0n)
|
|
510
|
+
return unit(s / 60n, 'minute', 'minutes');
|
|
511
|
+
return unit(s, 'second', 'seconds');
|
|
512
|
+
}
|
|
513
|
+
const timesWords = (n) => (n === 1n ? 'once' : n === 2n ? 'twice' : `${group(n)} times`);
|
|
514
|
+
/** A wallet address shortened for a sentence. */
|
|
515
|
+
export const shortWallet = (wallet) => (wallet.length > 12 ? `${wallet.slice(0, 4)}…${wallet.slice(-4)}` : wallet);
|
|
516
|
+
const SIDE_ONE = { sell: 'sell', buy: 'buy', send: 'transfer', receive: 'transfer', burn: 'burn' };
|
|
517
|
+
const SIDE_MANY = { sell: 'sells', buy: 'buys', send: 'transfers', receive: 'transfers', burn: 'burns' };
|
|
518
|
+
const joinOr = (words) => (words.length <= 1 ? (words[0] ?? '') : `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`);
|
|
519
|
+
const dedupe = (words) => words.filter((w, i) => words.indexOf(w) === i);
|
|
520
|
+
export function sideWords(on) {
|
|
521
|
+
const ordered = RULE_SIDES.filter((s) => on.includes(s));
|
|
522
|
+
return { one: joinOr(dedupe(ordered.map((s) => SIDE_ONE[s]))), many: joinOr(dedupe(ordered.map((s) => SIDE_MANY[s]))), verbs: joinOr(ordered) };
|
|
523
|
+
}
|
|
524
|
+
const OP_WORDS = { lt: 'under', le: 'at most', gt: 'over', ge: 'at least', eq: 'exactly', ne: 'other than' };
|
|
525
|
+
const phrase = (when, relative, complement = null, unless = `unless ${relative}`, trailing = null) => ({ when, relative, trailing, unless, complement });
|
|
526
|
+
/** "more than 5%", "5% or more", "less than 5%", "5% or less", "exactly 5%", "other than 5%". */
|
|
527
|
+
function bound(op, x) {
|
|
528
|
+
switch (op) {
|
|
529
|
+
case 'gt':
|
|
530
|
+
return `more than ${x}`;
|
|
531
|
+
case 'ge':
|
|
532
|
+
return `${x} or more`;
|
|
533
|
+
case 'lt':
|
|
534
|
+
return `less than ${x}`;
|
|
535
|
+
case 'le':
|
|
536
|
+
return `${x} or less`;
|
|
537
|
+
case 'eq':
|
|
538
|
+
return `exactly ${x}`;
|
|
539
|
+
case 'ne':
|
|
540
|
+
return `other than ${x}`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** The complement of a bound: who is on the other side of it. */
|
|
544
|
+
function otherSide(op, x) {
|
|
545
|
+
switch (op) {
|
|
546
|
+
case 'gt':
|
|
547
|
+
return `${x} or less`;
|
|
548
|
+
case 'ge':
|
|
549
|
+
return `less than ${x}`;
|
|
550
|
+
case 'lt':
|
|
551
|
+
return `${x} or more`;
|
|
552
|
+
case 'le':
|
|
553
|
+
return `more than ${x}`;
|
|
554
|
+
default:
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** A 0-or-1 value's reading: true, false, or neither (a comparison that holds for both or neither). */
|
|
559
|
+
function truth(atom) {
|
|
560
|
+
const holds = (v) => {
|
|
561
|
+
switch (atom.op) {
|
|
562
|
+
case 'lt':
|
|
563
|
+
return v < atom.literal;
|
|
564
|
+
case 'le':
|
|
565
|
+
return v <= atom.literal;
|
|
566
|
+
case 'gt':
|
|
567
|
+
return v > atom.literal;
|
|
568
|
+
case 'ge':
|
|
569
|
+
return v >= atom.literal;
|
|
570
|
+
case 'eq':
|
|
571
|
+
return v === atom.literal;
|
|
572
|
+
case 'ne':
|
|
573
|
+
return v !== atom.literal;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const t0 = holds(0n);
|
|
577
|
+
const t1 = holds(1n);
|
|
578
|
+
return t1 && !t0 ? true : t0 && !t1 ? false : null;
|
|
579
|
+
}
|
|
580
|
+
const smallerBags = (w) => `Smaller bags ${w.one} free.`;
|
|
581
|
+
const biggerBags = (w) => `Bigger bags ${w.one} free.`;
|
|
582
|
+
const above = (op) => op === 'gt' || op === 'ge';
|
|
583
|
+
const below = (op) => op === 'lt' || op === 'le';
|
|
584
|
+
/** The phrases of one atom (the table of §3, every value and op given a reading so the function is total). */
|
|
585
|
+
export function phraseOf(atom, symbol) {
|
|
586
|
+
const { value, op, literal: x } = atom;
|
|
587
|
+
const sym = symbol?.trim() || 'tokens';
|
|
588
|
+
switch (value) {
|
|
589
|
+
case 'gain':
|
|
590
|
+
case 'loss': {
|
|
591
|
+
const under = value === 'loss';
|
|
592
|
+
const inState = under ? 'under water' : 'in profit';
|
|
593
|
+
const outState = under ? 'in profit' : 'under water';
|
|
594
|
+
if ((op === 'gt' || op === 'ne') && x === 0n)
|
|
595
|
+
return phrase(`by a holder ${inState}`, inState, (w) => `Holders ${outState} ${w.one} free.`);
|
|
596
|
+
if ((op === 'eq' || op === 'le') && x === 0n)
|
|
597
|
+
return phrase(`by a holder not ${inState}`, `not ${inState}`, (w) => `Holders ${inState} ${w.one} free.`);
|
|
598
|
+
const dir = under ? 'down' : 'up';
|
|
599
|
+
const b = bound(op, pctWords(x));
|
|
600
|
+
const other = otherSide(op, pctWords(x));
|
|
601
|
+
return phrase(`by a holder ${dir} ${b}`, `${dir} ${b}`, other ? (w) => `Holders ${dir} ${other} ${w.one} free.` : null);
|
|
602
|
+
}
|
|
603
|
+
case 'valueBefore':
|
|
604
|
+
case 'valueAfter': {
|
|
605
|
+
const sol = `${solWords(x)} SOL`;
|
|
606
|
+
const worth = value === 'valueAfter' ? 'would be worth' : 'is worth';
|
|
607
|
+
const words = op === 'gt' ? `over ${sol}` : op === 'lt' ? `under ${sol}` : bound(op, sol);
|
|
608
|
+
const rel = `whose bag ${worth} ${words}${value === 'valueAfter' ? ' afterwards' : ''}`;
|
|
609
|
+
return phrase(`by a holder ${rel}`, rel, above(op) ? smallerBags : below(op) ? biggerBags : null);
|
|
610
|
+
}
|
|
611
|
+
case 'balanceBefore': {
|
|
612
|
+
const b = bound(op, tokenWords(x, sym));
|
|
613
|
+
return phrase(`by a holder with ${b}`, `holding ${b}`, above(op) ? smallerBags : below(op) ? biggerBags : null);
|
|
614
|
+
}
|
|
615
|
+
case 'balanceAfter': {
|
|
616
|
+
const rel = `that would leave a wallet with ${bound(op, tokenWords(x, sym))}`;
|
|
617
|
+
return phrase(rel, rel);
|
|
618
|
+
}
|
|
619
|
+
case 'shareAfter': {
|
|
620
|
+
const p = pctWords(x);
|
|
621
|
+
const words = op === 'gt' ? `over ${p}` : op === 'lt' ? `under ${p}` : op === 'ge' ? `at ${p} or more` : op === 'le' ? `at ${p} or less` : `at ${bound(op, p)}`;
|
|
622
|
+
const rel = `that would leave a wallet ${words} of the supply`;
|
|
623
|
+
return phrase(rel, rel);
|
|
624
|
+
}
|
|
625
|
+
case 'amount':
|
|
626
|
+
case 'amountShare': {
|
|
627
|
+
const b = bound(op, value === 'amount' ? tokenWords(x, sym) : `${pctWords(x)} of the supply`);
|
|
628
|
+
return phrase(`of ${b}`, `moving ${b}`, above(op) ? (w) => `Smaller ${w.many} pass free.` : below(op) ? (w) => `Bigger ${w.many} pass free.` : null);
|
|
629
|
+
}
|
|
630
|
+
case 'sinceReceived':
|
|
631
|
+
case 'sinceSent': {
|
|
632
|
+
const t = durationWords(x);
|
|
633
|
+
const got = value === 'sinceReceived' ? 'received' : 'sent';
|
|
634
|
+
const getting = value === 'sinceReceived' ? 'receiving' : 'sending';
|
|
635
|
+
if (op === 'lt' || op === 'le')
|
|
636
|
+
return phrase(`within ${t} of ${getting} tokens`, `that ${got} tokens ${op === 'lt' ? `less than ${t}` : `${t} or less`} ago`, () => `After ${t}, free.`);
|
|
637
|
+
if (op === 'gt' || op === 'ge')
|
|
638
|
+
return phrase(`${op === 'gt' ? `more than ${t}` : `${t} or more`} after ${getting} tokens`, `that ${got} tokens ${op === 'gt' ? `more than ${t}` : `${t} or more`} ago`, () => `Within ${t}, free.`);
|
|
639
|
+
const rel = `that ${got} tokens ${bound(op, t)} ago`;
|
|
640
|
+
return phrase(`by a wallet ${rel}`, rel);
|
|
641
|
+
}
|
|
642
|
+
case 'age': {
|
|
643
|
+
const t = durationWords(x);
|
|
644
|
+
const rel = `that first got tokens ${bound(op, t)} ago`;
|
|
645
|
+
return phrase(`by a wallet ${rel}`, rel, below(op) ? () => `After ${t}, free.` : above(op) ? (w) => `Newer wallets ${w.one} free.` : null);
|
|
646
|
+
}
|
|
647
|
+
case 'firstAfterLaunch': {
|
|
648
|
+
const t = durationWords(x);
|
|
649
|
+
const rel = below(op) ? `whose first tokens arrived in the first ${durationWords(x, false)} after launch` : `whose first tokens arrived ${bound(op, t)} after launch`;
|
|
650
|
+
return phrase(`by a wallet ${rel}`, rel, below(op) ? (w) => `Later wallets ${w.one} free.` : above(op) ? (w) => `Earlier wallets ${w.one} free.` : null);
|
|
651
|
+
}
|
|
652
|
+
case 'sinceLaunch': {
|
|
653
|
+
const t = durationWords(x);
|
|
654
|
+
const first = `in the first ${durationWords(x, false)} after launch`;
|
|
655
|
+
if (below(op))
|
|
656
|
+
return phrase(first, first, (w) => `After that, ${w.many} pass free.`, `unless ${first}`, `until ${t} after launch`);
|
|
657
|
+
if (above(op))
|
|
658
|
+
return phrase(`from ${t} after launch`, `from ${t} after launch`, (w) => `Before that, ${w.many} pass free.`, `unless from ${t} after launch`, `from ${t} after launch`);
|
|
659
|
+
const rel = `${bound(op, t)} after launch`;
|
|
660
|
+
return phrase(rel, rel, null, `unless ${rel}`, rel);
|
|
661
|
+
}
|
|
662
|
+
case 'tradePrice':
|
|
663
|
+
case 'spotPrice': {
|
|
664
|
+
const p = `${priceWords(x)} SOL per ${sym}`;
|
|
665
|
+
const words = op === 'gt' ? `over ${p}` : op === 'lt' ? `under ${p}` : bound(op, p);
|
|
666
|
+
const rel = value === 'tradePrice' ? `at a price ${words}` : `while the price is ${words}`;
|
|
667
|
+
return phrase(rel, rel, above(op) ? (w) => `At lower prices, ${w.many} pass free.` : below(op) ? (w) => `At higher prices, ${w.many} pass free.` : null);
|
|
668
|
+
}
|
|
669
|
+
case 'counter': {
|
|
670
|
+
const n = timesWords(x);
|
|
671
|
+
const rel = `counted ${op === 'gt' ? `more than ${n}` : op === 'ge' ? `${n} or more` : op === 'lt' ? `fewer than ${n}` : op === 'le' ? `${n} or fewer` : bound(op, n)}`;
|
|
672
|
+
return phrase(`by a holder ${rel}`, rel);
|
|
673
|
+
}
|
|
674
|
+
case 'graduated': {
|
|
675
|
+
const t = truth(atom);
|
|
676
|
+
if (t === true)
|
|
677
|
+
return phrase('after graduation', 'after graduation', null, 'until graduation');
|
|
678
|
+
if (t === false)
|
|
679
|
+
return phrase('before graduation', 'before graduation', null, 'after graduation');
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
case 'hasEntry': {
|
|
683
|
+
const t = truth(atom);
|
|
684
|
+
if (t === true)
|
|
685
|
+
return phrase('by a holder with an entry price', 'with an entry price', null, 'unless it has an entry price');
|
|
686
|
+
if (t === false)
|
|
687
|
+
return phrase('by a holder with no entry price', 'with no entry price', null, 'unless it has no entry price');
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'flag0':
|
|
691
|
+
case 'flag1':
|
|
692
|
+
case 'flag2':
|
|
693
|
+
case 'flag3': {
|
|
694
|
+
const k = RULE_VALUE_IDS[value] - RULE_VALUE_IDS.flag0;
|
|
695
|
+
const mark = k === 0 ? 'marked' : `marked ${k}`;
|
|
696
|
+
const t = truth(atom);
|
|
697
|
+
if (t === true)
|
|
698
|
+
return phrase(`by a ${mark} holder`, `that is ${mark}`);
|
|
699
|
+
if (t === false)
|
|
700
|
+
return phrase(`by a holder not ${mark}`, `that is not ${mark}`);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// A comparison that always or never holds on a 0-or-1 value: said in the mechanical words.
|
|
705
|
+
const rel = `whose ${value} is ${OP_WORDS[op]} ${x.toString()}`;
|
|
706
|
+
return phrase(`by a holder ${rel}`, rel);
|
|
707
|
+
}
|
|
708
|
+
const SUBJECT_PREFIXES = ['by a holder ', 'by a wallet ', 'by a buyer '];
|
|
709
|
+
/** The when atoms as one clause: " by a holder in profit and whose bag is worth over 6.5 SOL". */
|
|
710
|
+
function whenClause(phrases) {
|
|
711
|
+
let out = '';
|
|
712
|
+
let prev = null;
|
|
713
|
+
for (const p of phrases) {
|
|
714
|
+
const prefix = SUBJECT_PREFIXES.find((s) => p.when.startsWith(s) && prev !== null && prev.startsWith(s));
|
|
715
|
+
out += prefix ? ` and ${p.when.slice(prefix.length)}` : ` ${p.when}`;
|
|
716
|
+
prev = p.when;
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
function destinationWords(set, opts) {
|
|
721
|
+
switch (set.destination) {
|
|
722
|
+
case 'burn':
|
|
723
|
+
return 'burned';
|
|
724
|
+
case 'creator':
|
|
725
|
+
return 'paid to the creator';
|
|
726
|
+
case 'named':
|
|
727
|
+
return `paid to ${opts.destinationWallet ? shortWallet(opts.destinationWallet) : 'a named wallet'}`;
|
|
728
|
+
default:
|
|
729
|
+
return 'paid nowhere';
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** The max-wallet shape (§3): buys and receives refused on the share a wallet would hold, alone, until graduation or for ever. */
|
|
733
|
+
function maxWalletSentence(r) {
|
|
734
|
+
if (r.then.kind !== 'refuse' || r.when.length !== 1 || sideMask(r.on) !== (RULE_SIDE_BITS.buy | RULE_SIDE_BITS.receive))
|
|
735
|
+
return null;
|
|
736
|
+
const [a] = r.when;
|
|
737
|
+
if (!a || a.value !== 'shareAfter' || (a.op !== 'gt' && a.op !== 'ge'))
|
|
738
|
+
return null;
|
|
739
|
+
if (r.unless && !(r.unless.value === 'graduated' && truth(r.unless) === true))
|
|
740
|
+
return null;
|
|
741
|
+
const p = pctWords(a.literal);
|
|
742
|
+
return `No wallet can hold ${a.op === 'gt' ? `more than ${p}` : `${p} or more`} of the supply${r.unless ? ' until graduation' : ''}.`;
|
|
743
|
+
}
|
|
744
|
+
const SENDER_SIDES = ['sell', 'send', 'burn'];
|
|
745
|
+
const capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
746
|
+
/** One rule's sentences (one or two), per §3's templates. */
|
|
747
|
+
export function renderRule(r, set, opts = {}) {
|
|
748
|
+
const w = sideWords(r.on);
|
|
749
|
+
const whens = r.when.map((a) => phraseOf(a, opts.symbol));
|
|
750
|
+
const unless = r.unless ? ` ${phraseOf(r.unless, opts.symbol).unless}` : '';
|
|
751
|
+
const out = [];
|
|
752
|
+
if (r.then.kind === 'cut') {
|
|
753
|
+
out.push(`Takes ${pctWords(r.then.bps)} of every ${w.one}${whenClause(whens)}${unless}, ${destinationWords(set, opts)}.`);
|
|
754
|
+
if (whens.length === 1) {
|
|
755
|
+
const c = whens[0].complement?.(w);
|
|
756
|
+
if (c)
|
|
757
|
+
out.push(c);
|
|
758
|
+
}
|
|
759
|
+
else if (whens.length > 1)
|
|
760
|
+
out.push(`Every other ${w.one} passes free.`);
|
|
761
|
+
}
|
|
762
|
+
else if (r.then.kind === 'refuse') {
|
|
763
|
+
const special = maxWalletSentence(r);
|
|
764
|
+
if (special)
|
|
765
|
+
out.push(special);
|
|
766
|
+
else {
|
|
767
|
+
const sender = r.on.some((s) => SENDER_SIDES.includes(s));
|
|
768
|
+
const subject = sender ? 'A wallet' : 'A buyer';
|
|
769
|
+
const relatives = whens.filter((p) => p.trailing === null).map((p) => p.relative);
|
|
770
|
+
const trailing = whens.flatMap((p) => (p.trailing === null ? [] : [p.trailing]));
|
|
771
|
+
const rel = relatives.length ? ` ${relatives.join(' and ')}` : '';
|
|
772
|
+
const tail = trailing.length ? ` ${trailing.join(' and ')}` : '';
|
|
773
|
+
out.push(`${subject}${rel} cannot ${w.verbs}${sender && !tail ? ' them' : ''}${tail}${unless}.`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (r.then.kind === 'count') {
|
|
777
|
+
out.push(r.then.what === 'add' ? `Counts each ${w.one} a holder makes${whenClause(whens)}${unless}.` : `Resets a holder’s count on each ${w.one}${whenClause(whens)}${unless}.`);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
const mark = r.then.flag === 0 ? '' : ` with mark ${r.then.flag}`;
|
|
781
|
+
out.push(`${r.then.set ? 'Marks' : 'Unmarks'} a wallet${mark} on its ${w.one}${whenClause(whens)}${unless}.`);
|
|
782
|
+
}
|
|
783
|
+
if (r.stop && r.then.kind !== 'refuse')
|
|
784
|
+
out.push(`No later rule applies to that ${w.one}.`);
|
|
785
|
+
return out.map(capital);
|
|
786
|
+
}
|
|
787
|
+
/** The most a set takes per side (§3): summed, never measured. */
|
|
788
|
+
export function mostTakesOf(set) {
|
|
789
|
+
const sum = (side) => set.rules.reduce((t, r) => t + (r.then.kind === 'cut' && r.on.includes(side) ? r.then.bps : 0), 0);
|
|
790
|
+
const sell = sum('sell');
|
|
791
|
+
const buy = sum('buy');
|
|
792
|
+
const transfer = sum('send') + sum('receive');
|
|
793
|
+
const sides = [
|
|
794
|
+
['sell', 'sells', sell],
|
|
795
|
+
['buy', 'buys', buy],
|
|
796
|
+
['transfer', 'transfers', transfer],
|
|
797
|
+
];
|
|
798
|
+
const takes = sides.filter(([, , bps]) => bps > 0).map(([one, , bps]) => `${pctWords(bps)} of a ${one}`);
|
|
799
|
+
const free = sides.filter(([, , bps]) => bps === 0).map(([, many]) => many);
|
|
800
|
+
const text = takes.length === 0 ? 'Takes nothing.' : `${takes.join(', ')}${free.length ? `; nothing from ${joinOr(free)}` : ''}`;
|
|
801
|
+
return { sell, buy, transfer, bps: Math.max(sell, buy, transfer), text };
|
|
802
|
+
}
|
|
803
|
+
const PRICE_OR_TIME = ['tradePrice', 'spotPrice', 'gain', 'loss', 'valueBefore', 'valueAfter', 'sinceReceived', 'sinceSent', 'age', 'firstAfterLaunch', 'sinceLaunch'];
|
|
804
|
+
/** What the engine keeps for a set, in words. */
|
|
805
|
+
export function keepsWords(set) {
|
|
806
|
+
const out = [];
|
|
807
|
+
if (set.keeps.entry)
|
|
808
|
+
out.push('an entry price per holding');
|
|
809
|
+
if (set.keeps.times)
|
|
810
|
+
out.push('when tokens arrived and left');
|
|
811
|
+
if (set.keeps.count)
|
|
812
|
+
out.push('a counter and marks');
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* The reading of a set (§3): the sentences in rule order, the most it takes, the flags and the
|
|
817
|
+
* generated notes. Total and deterministic: the same bytes give the same words in the SDK, on the
|
|
818
|
+
* site, in the API and in the engine's tests.
|
|
819
|
+
*/
|
|
820
|
+
export function renderRules(set, opts = {}) {
|
|
821
|
+
const sentences = set.rules.flatMap((r) => renderRule(r, set, opts));
|
|
822
|
+
const mostTakes = mostTakesOf(set);
|
|
823
|
+
const watch = [];
|
|
824
|
+
const cutSides = RULE_SIDES.filter((s) => set.rules.some((r) => r.then.kind === 'cut' && r.on.includes(s)));
|
|
825
|
+
if (cutSides.length > 0 && (set.destination === 'creator' || set.destination === 'named')) {
|
|
826
|
+
const whose = set.destination === 'creator' ? 'The creator’s' : `${opts.destinationWallet ? shortWallet(opts.destinationWallet) : 'The named wallet’s'}`;
|
|
827
|
+
watch.push({ severity: 'medium', text: `${whose}${set.destination === 'named' && opts.destinationWallet ? '’s' : ''} holding must exist for the cut to be paid; until then ${sideWords(cutSides).many} pass free.` });
|
|
828
|
+
}
|
|
829
|
+
if (set.keeps.entry)
|
|
830
|
+
watch.push({ severity: 'low', text: 'Entry prices are average cost: a buy at a higher price raises the holder’s basis.' });
|
|
831
|
+
if (set.rules.some((r) => [...r.when, ...(r.unless ? [r.unless] : [])].some((a) => PRICE_OR_TIME.includes(a.value))))
|
|
832
|
+
watch.push({ severity: 'low', text: 'After graduation, adding liquidity reads as a sell and removing it as a buy.' });
|
|
833
|
+
return { sentences, mostTakes, flags: rulesFlagsOf(set), watch, keeps: keepsWords(set) };
|
|
834
|
+
}
|
|
835
|
+
// ---- the synthetic reading ----------------------------------------------------------------------------------
|
|
836
|
+
/** The lane's own words on the site: the lane, its verdict word and the line over a composed hook's sentences. */
|
|
837
|
+
export const RULES_LANE = 'rules';
|
|
838
|
+
export const RULES_VERDICT_WORD = 'Read from its rules';
|
|
839
|
+
export const RULES_COMPOSED_LINE = 'Composed from Bordrless rules';
|
|
840
|
+
/** What the inspection says of a config that names the engine with no rules bound (§4). */
|
|
841
|
+
export const RULES_UNBOUND_PROBLEM = 'This config names the rules engine but has no rules bound: it cannot launch.';
|
|
842
|
+
export function rulesReadout(set, opts = {}) {
|
|
843
|
+
const words = renderRules(set, opts);
|
|
844
|
+
return {
|
|
845
|
+
version: set.version,
|
|
846
|
+
bytes: rulesToHex(encodeRules(set)),
|
|
847
|
+
sentences: words.sentences,
|
|
848
|
+
mostTakes: words.mostTakes,
|
|
849
|
+
flags: words.flags,
|
|
850
|
+
destination: set.destination,
|
|
851
|
+
destinationWallet: set.destination === 'named' ? (opts.destinationWallet ?? null) : null,
|
|
852
|
+
keeps: ['entry', 'times', 'count'].filter((k) => set.keeps[k]),
|
|
853
|
+
entryCapBps: set.entryCapBps,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
export function rulesHookReading(set, opts = {}) {
|
|
857
|
+
const words = renderRules(set, opts);
|
|
858
|
+
const title = opts.name?.trim() || 'Composed hook';
|
|
859
|
+
const does = words.sentences.join(' ');
|
|
860
|
+
const keeps = words.keeps.length ? joinOr(words.keeps) : 'nothing';
|
|
861
|
+
return {
|
|
862
|
+
workshopId: opts.workshopId ?? '',
|
|
863
|
+
name: title,
|
|
864
|
+
author: opts.author ?? '',
|
|
865
|
+
kind: 'hook',
|
|
866
|
+
reading: {
|
|
867
|
+
title,
|
|
868
|
+
does,
|
|
869
|
+
total: { bps: words.mostTakes.bps, text: words.mostTakes.text },
|
|
870
|
+
description: `${does} Composed from Bordrless rules and run by the rules engine; it keeps ${keeps}.`,
|
|
871
|
+
watch: words.watch,
|
|
872
|
+
at: opts.at ?? 0,
|
|
873
|
+
},
|
|
874
|
+
measuredCutBps: null,
|
|
875
|
+
verdict: 'rules',
|
|
876
|
+
lane: 'rules',
|
|
877
|
+
approvedBy: null,
|
|
878
|
+
rules: rulesReadout(set, opts),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/** Whether a reading the API answered is the lane's synthetic one (served without the set: `rulesReadingWords`). */
|
|
882
|
+
export const isRulesReading = (r) => typeof r === 'object' && r !== null && r.lane === RULES_LANE && r.verdict === RULES_LANE;
|
|
883
|
+
/** The reading as the API serves it (`ConfigInspection.hook`, `CustomHookInfo.workshop`): the words alone; the set itself travels once, as `customHook.rules`. */
|
|
884
|
+
export function rulesReadingWords(r) {
|
|
885
|
+
const { rules: _set, ...words } = r;
|
|
886
|
+
return words;
|
|
887
|
+
}
|
|
888
|
+
// ---- the engine's refusals (§1.2: a REFUSE rule's reason) ---------------------------------------------------
|
|
889
|
+
/**
|
|
890
|
+
* A `REFUSE` rule fails the operation with its reason's error (`1 cooling, 2 cap, 3 locked, 4 rule`).
|
|
891
|
+
* `RULES_REFUSAL_ERRORS` are the engine's error names, the ones the starter's `HookError` spells too
|
|
892
|
+
* (held to the engine's IDL by the SDK's tests); the API names a trade or a send the engine refused
|
|
893
|
+
* by `RULES_REFUSAL_CODES`, with `RULES_REFUSAL_TEXT`; the site's trade panel says its own line from
|
|
894
|
+
* the token's own rules (`rulesRefusalLine`).
|
|
895
|
+
*/
|
|
896
|
+
export const RULES_REFUSAL_ERRORS = { cooling: 'CoolingDown', cap: 'OverCap', locked: 'Locked', rule: 'Refused' };
|
|
897
|
+
export const RULES_REFUSAL_CODES = { cooling: 'rules_cooling', cap: 'rules_cap', locked: 'rules_locked', rule: 'rules_rule' };
|
|
898
|
+
export const RULES_REFUSAL_TEXT = {
|
|
899
|
+
cooling: 'The token’s rules refuse this for now: tokens that arrived recently can’t be sold or sent yet.',
|
|
900
|
+
cap: 'The token’s rules refuse this: it would take the wallet over the most one wallet can hold.',
|
|
901
|
+
locked: 'The token’s rules refuse this: this wallet’s tokens are locked for now.',
|
|
902
|
+
rule: 'The token’s rules refuse this for now.',
|
|
903
|
+
};
|
|
904
|
+
const REASONS = Object.keys(REFUSE_REASON_IDS);
|
|
905
|
+
/** The reason of an engine error by its name (`CoolingDown`); null for any other error. */
|
|
906
|
+
export const refuseReasonOfError = (name) => REASONS.find((r) => RULES_REFUSAL_ERRORS[r] === name) ?? null;
|
|
907
|
+
/** The reason of an API error code (`rules_cooling`); null for any other code. */
|
|
908
|
+
export const refuseReasonOfCode = (code) => REASONS.find((r) => RULES_REFUSAL_CODES[r] === code) ?? null;
|
|
909
|
+
// ---- the engine's error numbers (its IDL, `programs/rules_hook`) ---------------------------------------------
|
|
910
|
+
/** `config_kit_rules` -> `ConfigKitRules`: how Anchor names the engine's error for a refusal code. */
|
|
911
|
+
const errorNameOf = (code) => code.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
912
|
+
/**
|
|
913
|
+
* The engine's errors by number, as its IDL lists them: `bind`'s refusals first, 6000 plus their
|
|
914
|
+
* place in `RULES_ERROR_CODES` (`length` 6000 ... `config_creator` 6028), then the four reasons
|
|
915
|
+
* (`CoolingDown` 6029, `OverCap` 6030, `Locked` 6031, `Refused` 6032), then its own. A log names
|
|
916
|
+
* the error too (`Error Code: CoolingDown`), which `refuseReasonOfError` reads.
|
|
917
|
+
*/
|
|
918
|
+
export const RULES_ENGINE_ERRORS = Object.fromEntries([
|
|
919
|
+
...RULES_ERROR_CODES.map((code, i) => [6000 + i, errorNameOf(code)]),
|
|
920
|
+
...['CoolingDown', 'OverCap', 'Locked', 'Refused', 'NoRules', 'Unbound', 'WrongAccount', 'BadHookSigner', 'WrongMint', 'NotEligible', 'NotTheCompanion', 'NoRecord', 'MathOverflow'].map((name, i) => [6000 + RULES_ERROR_CODES.length + i, name]),
|
|
921
|
+
]);
|
|
922
|
+
/** The engine's error number of a refusal of `bind` (`config_flags` -> 6026). */
|
|
923
|
+
export const rulesErrorNumber = (code) => 6000 + RULES_ERROR_CODES.indexOf(code);
|
|
924
|
+
/** The refusal of `bind` an engine error number stands for; null for any other number. */
|
|
925
|
+
export function rulesErrorCodeOfNumber(n) {
|
|
926
|
+
const i = n - 6000;
|
|
927
|
+
return Number.isInteger(i) && i >= 0 && i < RULES_ERROR_CODES.length ? RULES_ERROR_CODES[i] : null;
|
|
928
|
+
}
|
|
929
|
+
/** The engine's error numbers of the four reasons. */
|
|
930
|
+
export const RULES_REFUSAL_NUMBERS = Object.fromEntries(REASONS.map((r) => [r, Number(Object.entries(RULES_ENGINE_ERRORS).find(([, name]) => name === RULES_REFUSAL_ERRORS[r])[0])]));
|
|
931
|
+
/** The reason of an engine error number (6029 -> `cooling`); null for any other number. */
|
|
932
|
+
export const refuseReasonOfNumber = (n) => refuseReasonOfError(RULES_ENGINE_ERRORS[n]);
|
|
933
|
+
const PAST = { sell: 'sold', buy: 'bought', send: 'sent', receive: 'received', burn: 'burned' };
|
|
934
|
+
/**
|
|
935
|
+
* What a refused operation says, from the set that refused it: the first `REFUSE` rule with that
|
|
936
|
+
* reason on that side (else on any side). A cooldown on the time since tokens arrived says it the
|
|
937
|
+
* trade panel's way ("Tokens that arrived less than an hour ago can’t be sold yet."); any other is
|
|
938
|
+
* the rule's own sentence. Null when the set has no such rule.
|
|
939
|
+
*/
|
|
940
|
+
export function rulesRefusalLine(set, reason, side, opts = {}) {
|
|
941
|
+
const refusing = set.rules.filter((r) => r.then.kind === 'refuse' && r.then.reason === reason);
|
|
942
|
+
const rule = refusing.find((r) => r.on.includes(side)) ?? refusing[0];
|
|
943
|
+
if (!rule)
|
|
944
|
+
return null;
|
|
945
|
+
const since = reason === 'cooling' ? rule.when.find((a) => a.value === 'sinceReceived' && (a.op === 'lt' || a.op === 'le')) : undefined;
|
|
946
|
+
if (since)
|
|
947
|
+
return `Tokens that arrived ${since.op === 'lt' ? 'less than' : 'at most'} ${durationWords(since.literal)} ago can’t be ${PAST[rule.on.includes(side) ? side : rule.on[0]]} yet.`;
|
|
948
|
+
return renderRule(rule, set, opts)[0] ?? null;
|
|
949
|
+
}
|