@xgamesfun/solana 0.0.0-stage → 0.24.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/DISCLAIMER.md +41 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +172 -2
- package/bin/xsolana.mjs +189 -0
- package/examples/artwork-auction.json +51 -0
- package/examples/blind-forge.json +128 -0
- package/examples/coin-flip-arena.json +103 -0
- package/examples/duel-arena.json +137 -0
- package/examples/elements-set.json +274 -0
- package/examples/event-badges.json +67 -0
- package/examples/expedition-guild.json +154 -0
- package/examples/fair-presale.json +48 -0
- package/examples/falling-drop.json +90 -0
- package/examples/guild-missions.json +137 -0
- package/examples/halving-seasons.json +107 -0
- package/examples/hero-trials.json +93 -0
- package/examples/ideas.json +789 -0
- package/examples/level-ladder.json +65 -0
- package/examples/merge-2048.json +128 -0
- package/examples/nft-raffle.json +33 -0
- package/examples/paper-hands-farm.json +44 -0
- package/examples/partner-vault.json +112 -0
- package/examples/sacrifice-raffle.json +118 -0
- package/examples/scorched-treasury.json +84 -0
- package/examples/shelter-fair.json +164 -0
- package/examples/spark-pity.json +153 -0
- package/examples/stakers-draw.json +118 -0
- package/examples/star-fleet.json +150 -0
- package/examples/starforce.json +116 -0
- package/examples/stud-farm.json +134 -0
- package/examples/team-vesting.json +70 -0
- package/examples/trade-in-shop.json +168 -0
- package/js/approve/details.mjs +263 -0
- package/js/approve/ed25519.mjs +36 -0
- package/js/approve/live-check.mjs +144 -0
- package/js/approve/page.html +86 -0
- package/js/approve/page.js +203 -0
- package/js/approve/server.mjs +726 -0
- package/js/chain/base58.js +73 -0
- package/js/chain/borsh.js +270 -0
- package/js/chain/bytes.js +141 -0
- package/js/chain/core.js +181 -0
- package/js/chain/curve.js +52 -0
- package/js/chain/lookup.js +279 -0
- package/js/chain/mint.js +96 -0
- package/js/chain/pda.js +66 -0
- package/js/chain/programs.js +102 -0
- package/js/chain/rpc.js +219 -0
- package/js/chain/sha256.js +68 -0
- package/js/chain/token.js +85 -0
- package/js/chain/token2022.js +247 -0
- package/js/chain/tx.js +224 -0
- package/js/client/accounts.js +679 -0
- package/js/client/compressed.js +46 -0
- package/js/client/grant.js +299 -0
- package/js/client/idl/loot.js +2 -0
- package/js/client/idl/loot.json +17206 -0
- package/js/client/index.js +10 -0
- package/js/client/ix.js +2887 -0
- package/js/client/pda.js +114 -0
- package/js/client/policy.js +69 -0
- package/js/client/random.js +70 -0
- package/js/client/sizes.js +297 -0
- package/js/client/traits.js +44 -0
- package/js/connect/connect.mjs +182 -0
- package/js/economy/check.mjs +2236 -0
- package/js/economy/cost.mjs +185 -0
- package/js/economy/examples.mjs +114 -0
- package/js/economy/explain.mjs +239 -0
- package/js/economy/guide.mjs +232 -0
- package/js/economy/legal.mjs +15 -0
- package/js/economy/live.mjs +130 -0
- package/js/economy/model.mjs +698 -0
- package/js/economy/plan.mjs +1155 -0
- package/js/economy/schema.json +3071 -0
- package/js/economy/secrets.mjs +60 -0
- package/js/economy/simulate.mjs +1019 -0
- package/js/economy/txsize.mjs +45 -0
- package/js/economy/validate.mjs +85 -0
- package/js/index.js +10 -0
- package/js/mcp/server.mjs +113 -0
- package/js/mcp/tools.mjs +485 -0
- package/js/play/index.js +2855 -0
- package/js/play/keys.js +167 -0
- package/js/results/index.js +184 -0
- package/js/results/wire.js +90 -0
- package/package.json +100 -4
- package/release.json +8 -0
- package/skills/xsolana-economy/SKILL.md +109 -0
- package/skills/xsolana-economy/references/after-launch.md +124 -0
- package/skills/xsolana-economy/references/apps.md +193 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +180 -0
- package/skills/xsolana-economy/references/telling-the-person.md +134 -0
- package/types/chain/base58.d.ts +10 -0
- package/types/chain/borsh.d.ts +23 -0
- package/types/chain/bytes.d.ts +45 -0
- package/types/chain/core.d.ts +52 -0
- package/types/chain/curve.d.ts +1 -0
- package/types/chain/lookup.d.ts +194 -0
- package/types/chain/mint.d.ts +25 -0
- package/types/chain/pda.d.ts +25 -0
- package/types/chain/programs.d.ts +65 -0
- package/types/chain/rpc.d.ts +68 -0
- package/types/chain/sha256.d.ts +1 -0
- package/types/chain/token.d.ts +141 -0
- package/types/chain/token2022.d.ts +53 -0
- package/types/chain/tx.d.ts +60 -0
- package/types/client/accounts.d.ts +318 -0
- package/types/client/compressed.d.ts +29 -0
- package/types/client/grant.d.ts +170 -0
- package/types/client/idl/loot.d.ts +4498 -0
- package/types/client/index.d.ts +9 -0
- package/types/client/ix.d.ts +5315 -0
- package/types/client/pda.d.ts +95 -0
- package/types/client/policy.d.ts +48 -0
- package/types/client/random.d.ts +26 -0
- package/types/client/sizes.d.ts +101 -0
- package/types/client/traits.d.ts +12 -0
- package/types/economy/check.d.mts +100 -0
- package/types/economy/cost.d.mts +69 -0
- package/types/economy/examples.d.mts +27 -0
- package/types/economy/explain.d.mts +19 -0
- package/types/economy/guide.d.mts +140 -0
- package/types/economy/legal.d.mts +12 -0
- package/types/economy/live.d.mts +124 -0
- package/types/economy/model.d.mts +431 -0
- package/types/economy/plan.d.mts +1014 -0
- package/types/economy/secrets.d.mts +4 -0
- package/types/economy/simulate.d.mts +164 -0
- package/types/economy/txsize.d.mts +17 -0
- package/types/economy/validate.d.mts +3 -0
- package/types/index.d.ts +9 -0
- package/types/mcp/tools.d.mts +635 -0
- package/types/play/index.d.ts +880 -0
- package/types/play/keys.d.ts +51 -0
- package/types/results/index.d.ts +155 -0
- package/types/results/wire.d.ts +122 -0
|
@@ -0,0 +1,2236 @@
|
|
|
1
|
+
// The economy checker. Refusals stop an economy from going live: each has a code, what it means for the owner and
|
|
2
|
+
// the fix. Warnings are what the owner should know before going live. Pure.
|
|
3
|
+
//
|
|
4
|
+
// Most refusals are the program's own (cited by the program's source file), caught here so the owner hears about
|
|
5
|
+
// them in words before a wallet is ever asked. The rest are traps the program's notes name: a farm that empties a
|
|
6
|
+
// lottery's pot, a pool nothing fills, a recipe too big to send, a prize its pool cannot pay.
|
|
7
|
+
import IDL from '../client/idl/loot.js';
|
|
8
|
+
import { gateList } from '../client/grant.js';
|
|
9
|
+
import { MAX_BID_NFTS, MAX_SHARES } from '../client/ix.js';
|
|
10
|
+
import { vault } from '../client/pda.js';
|
|
11
|
+
import { boxBytes, buyFits, drawsFit, MAX_ORDER_BYTES, tableBudget } from '../client/sizes.js';
|
|
12
|
+
import { TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
|
|
13
|
+
import { addressBytes, encodeBase58 } from '../chain/base58.js';
|
|
14
|
+
import { isOnCurve } from '../chain/curve.js';
|
|
15
|
+
import { utf8 } from '../chain/bytes.js';
|
|
16
|
+
import { sha256 } from '../chain/sha256.js';
|
|
17
|
+
import {
|
|
18
|
+
FEES,
|
|
19
|
+
rent as rentOf,
|
|
20
|
+
LIMITS,
|
|
21
|
+
RESERVED_ATTRIBUTES,
|
|
22
|
+
destination,
|
|
23
|
+
fromUnits,
|
|
24
|
+
isAddress,
|
|
25
|
+
listOf,
|
|
26
|
+
lotterySplit,
|
|
27
|
+
model,
|
|
28
|
+
placeOf,
|
|
29
|
+
poolFillers,
|
|
30
|
+
farmFillers,
|
|
31
|
+
outcomeParts,
|
|
32
|
+
boxBuys,
|
|
33
|
+
boxRolls,
|
|
34
|
+
drawsOf,
|
|
35
|
+
lifeLines,
|
|
36
|
+
prizeLine,
|
|
37
|
+
prizePool,
|
|
38
|
+
prizeToken,
|
|
39
|
+
rollsOf,
|
|
40
|
+
prizes,
|
|
41
|
+
prizeWords,
|
|
42
|
+
releaseSeconds,
|
|
43
|
+
releaseWords,
|
|
44
|
+
farmEndWords,
|
|
45
|
+
stashNfts,
|
|
46
|
+
poolTakers,
|
|
47
|
+
sharesOf,
|
|
48
|
+
toBps,
|
|
49
|
+
toBy,
|
|
50
|
+
toSeconds,
|
|
51
|
+
toTime,
|
|
52
|
+
toUnits,
|
|
53
|
+
utf8Bytes,
|
|
54
|
+
wholeCount,
|
|
55
|
+
weights,
|
|
56
|
+
} from './model.mjs';
|
|
57
|
+
// plan.mjs imports check() in turn: each module only calls the other's functions, never at load time.
|
|
58
|
+
import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepBytes } from './plan.mjs';
|
|
59
|
+
import { findSecrets } from './secrets.mjs';
|
|
60
|
+
import { simulate } from './simulate.mjs';
|
|
61
|
+
import { BUDGET, MAX_TRANSACTION_BYTES, transactionLocks, transactionSize, WALLET_ROOM } from './txsize.mjs';
|
|
62
|
+
import { validate } from './validate.mjs';
|
|
63
|
+
|
|
64
|
+
/** The most a u64 holds. */
|
|
65
|
+
const U64_MAX = 2n ** 64n - 1n;
|
|
66
|
+
|
|
67
|
+
/** A transaction's estimated bytes, or with `detail` { bytes, locks }. */
|
|
68
|
+
const measured = (input, detail) => (detail ? { bytes: transactionSize(input), locks: transactionLocks(input) } : transactionSize(input));
|
|
69
|
+
|
|
70
|
+
const label = (part) => `“${part?.name ?? part?.key}”`;
|
|
71
|
+
|
|
72
|
+
/** Every place a spec gives a share of a pool as a prize, as a path ("economy.boxes[0].outcomes[1]"): the program has none. */
|
|
73
|
+
function sharePrizes(value, path = 'economy', found = []) {
|
|
74
|
+
if (Array.isArray(value)) value.forEach((v, i) => sharePrizes(v, `${path}[${i}]`, found));
|
|
75
|
+
else if (value && typeof value === 'object') {
|
|
76
|
+
if (value.gives === 'share') found.push(path);
|
|
77
|
+
for (const [k, v] of Object.entries(value)) if (k !== 'live') sharePrizes(v, `${path}.${k}`, found);
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Two prizes of one box outcome that want the same thing from one source (prize.rs `wants_same`): the words for them,
|
|
84
|
+
* or null. An outcome gives all it names or isn't given, so it names a pool once with the whole amount, a stash's
|
|
85
|
+
* random NFT beside nothing else of that stash, and a named NFT once. An item may repeat: two of it is how it gives two.
|
|
86
|
+
*/
|
|
87
|
+
function samePrizes(x, y) {
|
|
88
|
+
if (x.gives === 'pay' && y.gives === 'pay' && x.pool === y.pool) return `pays from the pool “${x.pool}” twice`;
|
|
89
|
+
if (x.gives === 'stash' && y.gives === 'stash' && x.stash === y.stash) return `gives two random NFTs of the stash “${x.stash}”`;
|
|
90
|
+
if ((x.gives === 'stash' && y.gives === 'nft' && x.stash === y.stash) || (x.gives === 'nft' && y.gives === 'stash' && x.stash === y.stash)) return `gives a random NFT of the stash “${x.stash}” beside a named one of it`;
|
|
91
|
+
if (x.gives === 'nft' && y.gives === 'nft' && x.stash === y.stash && x.nft === y.nft) return `names the NFT ${x.nft} twice`;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/** The bytes of a loot instruction's tag: 1, or 8 for the oracle's callbacks. */
|
|
95
|
+
const tagBytes = (name) => IDL.instructions.find((i) => i.name === name).discriminator.length;
|
|
96
|
+
const isCollectible = (it) => (it?.kind ?? 'collectible') === 'collectible';
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------- a player's craft, in bytes
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The transaction a player sends to craft `recipe`, estimated (txsize.mjs): the craft's own accounts, then each
|
|
102
|
+
* line's (craft.rs; a roll's box and order), a compute limit (a big recipe needs more
|
|
103
|
+
* than the default) and, for each token it gives, the player's token account created if missing.
|
|
104
|
+
*/
|
|
105
|
+
export function craftSize(recipe, m, { detail = false } = {}) {
|
|
106
|
+
const collectionOf = (key) => `collection:${m.item.get(key)?.collection ?? 'project'}`;
|
|
107
|
+
const fixed = ['wallet:player', 'pda:project', `pda:recipe/${recipe.key}`, 'program:mpl-core', 'program:token', 'program:token-2022', 'pda:platform', 'pda:fees', 'program:system'];
|
|
108
|
+
const rest = [];
|
|
109
|
+
const signers = ['wallet:player'];
|
|
110
|
+
const extra = [{ program: 'compute-budget', accounts: [], data: 5 }];
|
|
111
|
+
let asset = 0;
|
|
112
|
+
const mintOf = (key) => `mint:${key ?? m.currencyKey}`;
|
|
113
|
+
for (const line of recipe.in ?? []) {
|
|
114
|
+
// An item line passes its item and its collection (craft.rs).
|
|
115
|
+
if (line.takes === 'burn') rest.push(`pda:item/${line.item}`, `${collectionOf(line.item)}`, ...Array.from({ length: line.count ?? 1 }, () => `asset:${asset++}`));
|
|
116
|
+
else if (line.takes === 'use' || line.takes === 'renew') rest.push(`pda:item/${line.item}`, `${collectionOf(line.item)}`, `asset:${asset++}`);
|
|
117
|
+
// A staked one: its seat and the farm its line names follow it.
|
|
118
|
+
else if (line.takes === 'staked') rest.push(`pda:item/${line.item}`, `${collectionOf(line.item)}`, `asset:${asset}`, `pda:seat/${asset++}`, `pda:farm/${line.farm}`);
|
|
119
|
+
// The largest a shown NFT takes: a Token Metadata one's mint, metadata, token account and Sheet.
|
|
120
|
+
else if (line.takes === 'show') rest.push(`asset:${asset}`, `metadata:${asset}`, `ata:player/${asset}`, `sheet:${asset++}`);
|
|
121
|
+
// A trade-in: the NFT's every slot (`piece`, the largest a pNFT's), then where it goes.
|
|
122
|
+
else if (line.takes === 'trade') rest.push(...Array.from({ length: PIECE_SLOTS }, (_, k) => (k < 9 ? `trade:${asset}/${k}` : 'program:loot')), `to:${asset++}`);
|
|
123
|
+
else if (line.takes === 'pay') {
|
|
124
|
+
rest.push(mintOf(line.currency), `ata:player/${line.currency ?? m.currencyKey}`);
|
|
125
|
+
const d = destination(line.to);
|
|
126
|
+
if (d.kind === 'pool') rest.push(`pda:pool/${d.key}`);
|
|
127
|
+
if (d.kind === 'account') rest.push(`account:${d.key}`);
|
|
128
|
+
if (d.kind === 'holder') rest.push(`asset:${d.key}`, `ata:holder/${d.key}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const gives = new Set();
|
|
132
|
+
// Each line's prize accounts (prize.rs): a collectible at its item's and serial's address (no signer), tokens'
|
|
133
|
+
// mint, pool and the player's account, an NFT's stash and its piece, a roll's box and order.
|
|
134
|
+
recipe.out?.forEach((line, i) => {
|
|
135
|
+
// A compressed one: the tree's config, the tree, the collection, then Bubblegum's four programs and its signer.
|
|
136
|
+
if (line.gives === 'item' && line.compressed) rest.push(`pda:item/${line.item}`, 'pda:tree-config', 'account:tree', `${collectionOf(line.item)}`, 'program:bubblegum', 'program:compression', 'program:noop', 'program:core', 'account:bubblegum-signer');
|
|
137
|
+
else if (line.gives === 'item') rest.push(`pda:item/${line.item}`, ...(m.item.get(line.item)?.kind === 'normal' ? [] : [`${collectionOf(line.item)}`, `pda:asset/${line.item}/${i}`, 'program:core']));
|
|
138
|
+
else if (line.gives === 'nft') rest.push(`pda:stash/${line.stash}`, ...Array.from({ length: PIECE_SLOTS }, (_, k) => (k === 0 ? `asset:nft/${i}` : k < 3 ? `nft/${i}/${k}` : 'program:loot')));
|
|
139
|
+
// A roll (given, or on a kept collectible): the box and the order (its roll is asked for after, `ask_roll`).
|
|
140
|
+
else if (line.gives === 'roll') rest.push(`pda:box/${line.box}`, `pda:order/${i}`);
|
|
141
|
+
else {
|
|
142
|
+
const currency = line.from?.startsWith('pool:') ? m.pool.get(line.from.slice(5))?.currency : line.currency ?? m.currencyKey;
|
|
143
|
+
const pool = line.from?.startsWith('pool:') ? line.from.slice(5) : null;
|
|
144
|
+
rest.push(mintOf(currency), ...(pool ? [`pda:pool/${pool}`] : []), `ata:player/${currency}`);
|
|
145
|
+
gives.add(currency);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
for (const currency of gives) extra.push({ program: 'associated-token', accounts: ['wallet:player', `ata:player/${currency}`, 'wallet:player', mintOf(currency), 'program:system', 'program:token'], data: 1 });
|
|
149
|
+
return measured({ signers, instructions: [...extra, { program: 'loot', accounts: [...fixed, ...rest], data: tagBytes('craft') + 4 + 8 + 8 }] }, detail);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------- a player's purchase of a box, in bytes
|
|
153
|
+
|
|
154
|
+
/** How many accounts `open_box`'s NFT slot (`piece`) takes, used or not. */
|
|
155
|
+
const PIECE_SLOTS = IDL.instructions.find((i) => i.name === 'open_box').accounts.find((a) => a.name === 'piece').accounts.length;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The transaction a player sends to open box `b`, estimated (txsize.mjs) as a wallet sends it: its compute limit and
|
|
159
|
+
* price, the player's token account of the box's currency created if missing (and for SOL, the wrap around the
|
|
160
|
+
* purchase), then `open_box` (order.rs): its own accounts (an NFT slot the program leaves empty stands in as the
|
|
161
|
+
* program's address), an account for each outcome that wins something, each prize mint with the game's vault for it,
|
|
162
|
+
* and the NFT the player shows for a gate or odds (at its largest, a Token Metadata one's mint, metadata, token
|
|
163
|
+
* account and Sheet).
|
|
164
|
+
*/
|
|
165
|
+
export function openBoxSize(b, m, { detail = false } = {}) {
|
|
166
|
+
const currency = b.currency ?? m.currencyKey;
|
|
167
|
+
const mint = (key) => `mint:${key}`;
|
|
168
|
+
const vault = (key) => `vault:${key}`;
|
|
169
|
+
const none = 'program:loot';
|
|
170
|
+
const outcomes = b.outcomes ?? [];
|
|
171
|
+
const limited = Boolean(b.perWallet || b.perWalletEach?.count);
|
|
172
|
+
const chosenItem = (b.split ?? []).some((s) => /^chosen:./.test(String(s.to)));
|
|
173
|
+
// The NFT it rolls on fills the `piece` slots: a Core collectible of the game's, else at its largest a pNFT's
|
|
174
|
+
// accounts (its records and rule set when the roll can move it).
|
|
175
|
+
const moves = outcomes.some((o) => (o.then ?? b.then) && (o.then ?? b.then) !== 'back');
|
|
176
|
+
const piece = !b.rollsOn
|
|
177
|
+
? [`pda:box/${b.key}`]
|
|
178
|
+
: b.rollsOn.item
|
|
179
|
+
? ['asset:rolled', 'sheet:rolled', ...(moves ? ['pda:collection', 'program:mpl-core'] : [])]
|
|
180
|
+
: ['asset:rolled', 'sheet:rolled', 'metadata:rolled', 'ata:player/rolled', ...(moves ? ['edition:rolled', 'record:player', 'ata:game/rolled', 'record:game', 'rule-set', 'program:auth-rules', 'program:token-metadata', 'sysvar:instructions'] : [])];
|
|
181
|
+
const own = [
|
|
182
|
+
`pda:box/${b.key}`,
|
|
183
|
+
'wallet:player', 'pda:project', 'pda:platform', 'pda:order', mint(currency), `ata:player/${currency}`, vault(currency),
|
|
184
|
+
'program:token', 'program:token-2022', 'program:system', 'program:associated-token',
|
|
185
|
+
...piece, ...Array.from({ length: PIECE_SLOTS - piece.length }, () => none),
|
|
186
|
+
// Every open asks the oracle.
|
|
187
|
+
'vrf:queue', 'pda:identity', 'program:vrf', 'sysvar:slot-hashes',
|
|
188
|
+
chosenItem ? 'asset:chosen' : none,
|
|
189
|
+
limited ? 'pda:counts/player' : none,
|
|
190
|
+
];
|
|
191
|
+
const rest = [];
|
|
192
|
+
const prizeMints = [];
|
|
193
|
+
for (const o of outcomes.flatMap(outcomeParts)) {
|
|
194
|
+
if (o.gives === 'item') rest.push(`pda:item/${o.item}`);
|
|
195
|
+
if (o.gives === 'stash' || o.gives === 'nft') rest.push(`pda:stash/${o.stash}`);
|
|
196
|
+
if (o.gives === 'pay' || o.gives === 'mint') {
|
|
197
|
+
if (o.gives !== 'mint') rest.push(`pda:pool/${o.pool}`);
|
|
198
|
+
const c = o.gives === 'mint' ? o.currency ?? m.currencyKey : m.pool.get(o.pool)?.currency;
|
|
199
|
+
if (c && !prizeMints.includes(c)) prizeMints.push(c);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const c of prizeMints) rest.push(mint(c), vault(c));
|
|
203
|
+
rest.push(...listProof(b.gate));
|
|
204
|
+
if (!b.rollsOn && ((showsNft(b.gate)) || b.odds?.length)) rest.push('asset:shown', 'metadata:shown', 'ata:player/shown', 'sheet:shown');
|
|
205
|
+
const ata = `ata:player/${currency}`;
|
|
206
|
+
const wallet = [{ program: 'compute-budget', accounts: [], data: 5 }, { program: 'compute-budget', accounts: [], data: 9 }];
|
|
207
|
+
const create = { program: 'associated-token', accounts: ['wallet:player', ata, 'wallet:player', mint(currency), 'program:system', 'program:token'], data: 1 };
|
|
208
|
+
const wrap = m.tokens.get(currency)?.wsol
|
|
209
|
+
? [{ program: 'system', accounts: ['wallet:player', ata], data: 12 }, { program: 'token', accounts: [ata], data: 1 }]
|
|
210
|
+
: [];
|
|
211
|
+
const unwrap = wrap.length ? [{ program: 'token', accounts: [ata, 'wallet:player', 'wallet:player'], data: 1 }] : [];
|
|
212
|
+
const data = tagBytes('open_box') + 8 + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4 + 4;
|
|
213
|
+
return measured({ signers: ['wallet:player'], instructions: [...wallet, create, ...wrap, { program: 'loot', accounts: [...own, ...rest], data }, ...unwrap] }, detail);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The longest proof a compressed NFT's tree can leave: Bubblegum v2 refuses a smaller canopy. */
|
|
217
|
+
export const LONGEST_PROOF = 17;
|
|
218
|
+
/** How many accounts a transaction may lock: so the most a settle can name. */
|
|
219
|
+
export const ACCOUNT_LOCKS = 64;
|
|
220
|
+
/** A v0 transaction's lookup table and the lookup-table program: the transaction deactivates the table it loads from. */
|
|
221
|
+
export const TABLE_LOCKS = 2;
|
|
222
|
+
/** More accounts than this never fit a legacy transaction (32 bytes each): such a transaction goes as v0. */
|
|
223
|
+
const LEGACY_MOST_ACCOUNTS = 35;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The accounts a settle of box `b` locks for outcome `o`, counted as the client builds it (`settleIx`, sent by anyone
|
|
227
|
+
* after a Compute Budget instruction, in a v0 transaction with a lookup table, the Sheets of NFTs it doesn't write left
|
|
228
|
+
* out), at its largest: { count, memo, picks, corePicks, payees, compressedSubject }. `memo`: a payee the project
|
|
229
|
+
* didn't pick (a holder's share, a buyer's chosen wallet, a wallet's) can turn on required memos for their own
|
|
230
|
+
* Token-2022 account at any time, and every settle then passes the memo program, one account more.
|
|
231
|
+
* Matches the program's own measurements: a roll on a compressed NFT of a 17-node
|
|
232
|
+
* proof is 41 with nothing won; each prize of tokens in a mint of its own 3, each holder's share 3, a hook asking
|
|
233
|
+
* one account of its own 3, a compressed NFT picked from a stash 22, a Core one of its own collection 2 (Core 1).
|
|
234
|
+
*/
|
|
235
|
+
export function settleAccounts(b, o, m, { proof = LONGEST_PROOF } = {}) {
|
|
236
|
+
const currency = b.currency ?? m.currencyKey;
|
|
237
|
+
const tok = m.tokens.get(currency);
|
|
238
|
+
const keys = new Set([
|
|
239
|
+
'program:compute-budget', 'program:loot', 'wallet:signer', 'pda:order', 'wallet:player', 'pda:project', `mint:${currency}`, `vault:${currency}`,
|
|
240
|
+
'wallet:treasury', `ata:treasury/${currency}`, 'program:token', 'program:token-2022', 'pda:fees', 'program:system', 'program:associated-token',
|
|
241
|
+
]);
|
|
242
|
+
const add = (...list) => list.forEach((k) => keys.add(k));
|
|
243
|
+
const bubblegum = () => add('program:bubblegum', 'program:compression', 'program:noop');
|
|
244
|
+
const compressed = (id) => {
|
|
245
|
+
add(`asset:${id}`, `collection:${id}`, `leaf:${id}`, `tree:${id}`, `tree-config:${id}`, ...Array.from({ length: proof }, (_, i) => `proof:${id}/${i}`));
|
|
246
|
+
bubblegum();
|
|
247
|
+
};
|
|
248
|
+
// The NFT it rolls on: one of a collection not the game's own may be compressed (a Bubblegum v2 NFT is in a Core
|
|
249
|
+
// collection), and its proof comes with it; the game's own is a Core asset. Its Sheet only when the roll moves a
|
|
250
|
+
// number on it.
|
|
251
|
+
const then = o.then ?? b.then;
|
|
252
|
+
const compressedSubject = Boolean(b.rollsOn?.collection);
|
|
253
|
+
if (b.rollsOn) {
|
|
254
|
+
if (compressedSubject) compressed('subject');
|
|
255
|
+
else add('asset:subject', `collection:${m.item.get(b.rollsOn.item)?.collection ?? 'project'}`, 'program:mpl-core');
|
|
256
|
+
if (o.shifts?.length) add('sheet:subject');
|
|
257
|
+
if (then && then !== 'back') add(then === 'treasury' ? 'wallet:treasury' : 'receiver');
|
|
258
|
+
}
|
|
259
|
+
// What it won, in order (`wonAccounts`).
|
|
260
|
+
let picks = 0;
|
|
261
|
+
let corePicks = 0;
|
|
262
|
+
for (const [i, x] of outcomeParts(o).entries()) {
|
|
263
|
+
if (x.gives === 'pay' || x.gives === 'mint') {
|
|
264
|
+
const c = x.gives === 'mint' ? x.currency ?? m.currencyKey : m.pool.get(x.pool)?.currency;
|
|
265
|
+
add(`mint:${c}`, `vault:${c}`, `ata:player/${c}`);
|
|
266
|
+
} else if (x.gives === 'item') {
|
|
267
|
+
const it = m.item.get(x.item);
|
|
268
|
+
add(`pda:item/${x.item}`);
|
|
269
|
+
if (isCollectible(it)) add(`asset:item/${i}`, `collection:${it?.collection ?? 'project'}`, 'program:mpl-core');
|
|
270
|
+
} else if (x.gives === 'roll') {
|
|
271
|
+
add(`pda:box/${x.box}`, `pda:order/roll${i}`);
|
|
272
|
+
} else if (x.gives === 'stash' || x.gives === 'nft') {
|
|
273
|
+
picks++;
|
|
274
|
+
const s = m.stash.get(x.stash);
|
|
275
|
+
const nfts = s ? stashNfts(s) : [];
|
|
276
|
+
const named = x.gives === 'nft' ? nfts.filter((n) => n.asset === x.nft) : nfts;
|
|
277
|
+
// A random pick may be any of the stash's: counted as its largest kind.
|
|
278
|
+
const id = `pick${i}`;
|
|
279
|
+
if (named.some((n) => n.kind === 'compressed')) compressed(id);
|
|
280
|
+
else if (named.some((n) => n.kind === 'token-metadata')) {
|
|
281
|
+
add(`mint:${id}`, `metadata:${id}`, `edition:${id}`, `ata:player/${id}`, `ata:project/${id}`, 'program:token-metadata', 'sysvar:instructions');
|
|
282
|
+
if (named.some((n) => n.programmable)) add(`record:player/${id}`, `record:project/${id}`, `rule-set:${id}`, 'program:auth-rules');
|
|
283
|
+
} else {
|
|
284
|
+
corePicks++;
|
|
285
|
+
const collections = new Set(named.map((n) => n.collection ?? 'project'));
|
|
286
|
+
add(`asset:${id}`, `collection:${collections.size === 1 ? [...collections][0] : id}`, 'program:mpl-core');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// The accounts the shares name (`destinationAccounts`): a holder's asset, wallet and token account; a wallet and its
|
|
291
|
+
// token account; a pool or an account; the treasury's are the settle's own.
|
|
292
|
+
let payees = 0;
|
|
293
|
+
for (const [j, s] of sharesOf(b.split).entries()) {
|
|
294
|
+
if (s.kind === 'holder' || s.kind === 'chosen') add(`asset:payee${j}`, `wallet:payee${j}`, `ata:payee${j}`);
|
|
295
|
+
else if (s.kind === 'wallet') add(`wallet:${s.key}`, `ata:${s.key}/${currency}`);
|
|
296
|
+
else if (s.kind === 'pool') add(`pda:pool/${s.key}`);
|
|
297
|
+
else if (s.kind === 'account' || s.kind === 'lottery') add(`account:${s.kind}/${s.key}`);
|
|
298
|
+
if (['holder', 'chosen', 'wallet'].includes(s.kind)) payees++;
|
|
299
|
+
}
|
|
300
|
+
// A transfer hook's own accounts: its program, its list of extra accounts and (as measured) one of its own.
|
|
301
|
+
if (tok?.powers?.transferHook) add('program:hook', 'hook:validation', 'hook:extra');
|
|
302
|
+
const token2022 = tok?.program === 'token-2022' || Boolean(tok?.powers?.transferHook || tok?.powers?.transferFeePercent || tok?.powers?.permanentDelegate || tok?.powers?.pausable || tok?.powers?.defaultState);
|
|
303
|
+
return { count: keys.size, keys: [...keys], memo: token2022 && payees > 0, picks, corePicks, payees, compressedSubject };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The compute a transaction may have: no settle can take more. */
|
|
307
|
+
export const MAX_COMPUTE_UNITS = 1_400_000;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The compute a settle of box `b`'s outcome `o` takes, estimated at its most from measurements on devnet's
|
|
311
|
+
* features: about 60,000 units for the settle itself, 250,000 for each Token Metadata
|
|
312
|
+
* NFT it moves (a pNFT's, with its rule set), 100,000 for a compressed one (or the NFT it rolls on, of a collection
|
|
313
|
+
* not the game's own), 60,000 for a Core one, 40,000 for each collectible it mints and 20,000 for a roll it makes, and
|
|
314
|
+
* 30,000 for each token payout (a prize, each share of the price, the treasury's), twice that in a coin with a
|
|
315
|
+
* transfer hook. Three pNFTs of stashes, the NFT rolled on and four holders' shares (measured 942,389 to 985,942)
|
|
316
|
+
* come out at about 1,170,000.
|
|
317
|
+
*/
|
|
318
|
+
export function settleUnits(b, o, m) {
|
|
319
|
+
const hooked = (c) => (m.tokens.get(c)?.powers?.transferHook ? 2 : 1);
|
|
320
|
+
const currency = b.currency ?? m.currencyKey;
|
|
321
|
+
let units = 60_000;
|
|
322
|
+
if (b.rollsOn) units += b.rollsOn.collection ? 100_000 : 60_000;
|
|
323
|
+
for (const x of outcomeParts(o)) {
|
|
324
|
+
if (x.gives === 'pay' || x.gives === 'mint') units += 30_000 * hooked(x.gives === 'mint' ? x.currency ?? m.currencyKey : m.pool.get(x.pool)?.currency);
|
|
325
|
+
else if (x.gives === 'item') units += isCollectible(m.item.get(x.item)) ? 40_000 : 0;
|
|
326
|
+
else if (x.gives === 'roll') units += 20_000;
|
|
327
|
+
else if (x.gives === 'stash' || x.gives === 'nft') {
|
|
328
|
+
const nfts = m.stash.get(x.stash) ? stashNfts(m.stash.get(x.stash)) : [];
|
|
329
|
+
const named = x.gives === 'nft' ? nfts.filter((n) => n.asset === x.nft) : nfts;
|
|
330
|
+
units += named.some((n) => n.kind === 'token-metadata') ? 250_000 : named.some((n) => n.kind === 'compressed') ? 100_000 : 60_000;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return units + 30_000 * hooked(currency) * (sharesOf(b.split).length + 1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The settle of box `b`'s outcome `o` as xsolana's senders build it (js/play, the keeper's sweep: `settleIx` after a
|
|
338
|
+
* compute limit, and a heap frame for three NFT moves or more), estimated (txsize.mjs) at its largest:
|
|
339
|
+
* { legacy: its bytes as a legacy transaction, tableRent: the lamports a lookup table of its accounts holds while it
|
|
340
|
+
* is open, locks: the accounts it locks (`settleAccounts`) }. A settle bigger than a legacy transaction goes as v0,
|
|
341
|
+
* its accounts loaded from a table its sender makes first.
|
|
342
|
+
*/
|
|
343
|
+
export function settleSize(b, o, m) {
|
|
344
|
+
const { keys, count, memo, compressedSubject } = settleAccounts(b, o, m);
|
|
345
|
+
const parts = outcomeParts(o);
|
|
346
|
+
const moves = parts.filter((x) => x.gives === 'stash' || x.gives === 'nft').length + (b.rollsOn ? 1 : 0);
|
|
347
|
+
// The settle's account list, with each slot it leaves empty (`settleIx`): its own 38, the proof of a compressed NFT
|
|
348
|
+
// it rolls on, each prize's (tokens 3, a collectible 4, a normal item 1, a roll 2, an NFT the 23 of a piece and a
|
|
349
|
+
// compressed one's proof), each share's (a holder's 3, a wallet's 2, a pool's or an account's 1), a hook's and a memo.
|
|
350
|
+
const compressedPick = (x) => (m.stash.get(x.stash) ? stashNfts(m.stash.get(x.stash)) : []).filter((n) => x.gives !== 'nft' || n.asset === x.nft).some((n) => n.kind === 'compressed');
|
|
351
|
+
const won = parts.reduce((n, x) => n + ({ pay: 3, share: 3, mint: 3, item: isCollectible(m.item.get(x.item)) ? 4 : 1, roll: 2, stash: PIECE_SLOTS, nft: PIECE_SLOTS }[x.gives] ?? 0) + ((x.gives === 'stash' || x.gives === 'nft') && compressedPick(x) ? LONGEST_PROOF : 0), 0);
|
|
352
|
+
const split = sharesOf(b.split).reduce((n, x) => n + ({ holder: 3, chosen: 3, wallet: 2, pool: 1, account: 1, lottery: 1 }[x.kind] ?? 0), 0);
|
|
353
|
+
const hooks = keys.filter((k) => k.startsWith('hook:') || k === 'program:hook').length;
|
|
354
|
+
const entries = 38 + (compressedSubject ? LONGEST_PROOF : 0) + won + split + hooks + (memo ? 1 : 0);
|
|
355
|
+
const named = keys.filter((k) => k !== 'program:compute-budget' && k !== 'program:loot');
|
|
356
|
+
// Its data: the tag, `upto` and `prize_accounts`.
|
|
357
|
+
const settle = { program: 'loot', accounts: [...named, ...Array.from({ length: Math.max(0, entries - named.length) }, () => 'program:loot')], data: tagBytes('settle') + 4 + 2 };
|
|
358
|
+
const budget = [{ program: 'compute-budget', accounts: [], data: 5 }, ...(moves >= 3 ? [{ program: 'compute-budget', accounts: [], data: 5 }] : [])];
|
|
359
|
+
// A table holds every account but the signer and the programs the transaction calls.
|
|
360
|
+
const held = keys.filter((k) => !['wallet:signer', 'program:compute-budget', 'program:loot'].includes(k)).length;
|
|
361
|
+
return { legacy: transactionSize({ signers: ['wallet:signer'], instructions: [...budget, settle] }), tableRent: rentOf(56 + 32 * held), locks: count };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** What a player passes ahead of an NFT shown: a list gate's proof, one account a level (`gateList`), or a stake gate's seat. */
|
|
365
|
+
const listProof = (gate) => (gate?.list ? Array.from({ length: gateList(gate.list).depth }, (_, i) => `proof:${i}`) : gate?.staked ? [`seat:${gate.staked.farm}`, `pda:farm/${gate.staked.farm}`] : []);
|
|
366
|
+
/** Whether a gate asks the player to show an NFT (a rule), not a list, a few wallets or a stake. */
|
|
367
|
+
const showsNft = (gate) => Boolean(gate && !gate.wallets && !gate.list && !gate.staked);
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The transaction a player sends to buy box `b` (buy.rs), estimated as `openBoxSize` is: its compute limit and price,
|
|
371
|
+
* the player's token account of the currency created if missing, then `buy`: its own accounts, what the gate takes
|
|
372
|
+
* (a list's proof, or the NFT shown), the accounts its shares name, and each prize's (prize.rs; a roll's box and
|
|
373
|
+
* order), for each draw of a sale (a fixed amount of tokens once). `count`: the sales in one purchase.
|
|
374
|
+
*/
|
|
375
|
+
export function buySize(b, m, { detail = false, count = 1 } = {}) {
|
|
376
|
+
const collectionOf = (key) => `collection:${m.item.get(key)?.collection ?? 'project'}`;
|
|
377
|
+
const currency = b.currency ?? m.currencyKey;
|
|
378
|
+
const none = 'program:loot';
|
|
379
|
+
const limited = Boolean(b.perWallet || b.perWalletEach?.count);
|
|
380
|
+
const chosenItem = (b.split ?? []).some((s) => /^chosen:./.test(String(s.to)));
|
|
381
|
+
const own = [
|
|
382
|
+
'wallet:player', 'pda:project', `pda:box/${b.key}`, `mint:${currency}`, `ata:player/${currency}`, 'wallet:treasury', `ata:treasury/${currency}`,
|
|
383
|
+
'program:token', 'program:token-2022', 'program:associated-token', chosenItem ? 'asset:chosen' : none, limited ? 'pda:counts/player' : none,
|
|
384
|
+
'pda:platform', 'pda:fees', 'program:system',
|
|
385
|
+
];
|
|
386
|
+
const rest = [...listProof(b.gate)];
|
|
387
|
+
if (showsNft(b.gate)) rest.push('asset:shown', 'metadata:shown', 'ata:player/shown', 'sheet:shown');
|
|
388
|
+
for (const s of sharesOf(b.split)) {
|
|
389
|
+
if (s.kind === 'pool') rest.push(`pda:pool/${s.key}`);
|
|
390
|
+
if (s.kind === 'account') rest.push(`account:${s.key}`);
|
|
391
|
+
if (s.kind === 'wallet' || s.kind === 'treasury' || s.kind === 'chosen') rest.push(`wallet:${s.key}`, `ata:${s.key}/${currency}`);
|
|
392
|
+
if (s.kind === 'holder') rest.push(`asset:${s.key}`, `wallet:holder/${s.key}`, `ata:holder/${s.key}`);
|
|
393
|
+
}
|
|
394
|
+
const draws = drawsOf(b) * count;
|
|
395
|
+
outcomeParts(b.outcomes?.[0]).flatMap((o) => Array(o.gives === 'mint' || o.gives === 'pay' ? 1 : draws).fill(o)).forEach((o, i) => {
|
|
396
|
+
if (o.gives === 'item') rest.push(`pda:item/${o.item}`, ...(m.item.get(o.item)?.kind === 'normal' ? [] : [collectionOf(o.item), `pda:asset/${o.item}/${i}`, 'program:mpl-core']));
|
|
397
|
+
else if (o.gives === 'nft') rest.push(`pda:stash/${o.stash}`, ...Array.from({ length: PIECE_SLOTS }, (_, k) => (k === 0 ? `asset:nft/${i}` : k < 3 ? `nft/${i}/${k}` : none)));
|
|
398
|
+
else if (o.gives === 'roll') rest.push(`pda:box/${o.box}`, `pda:order/${i}`);
|
|
399
|
+
else {
|
|
400
|
+
const c = o.gives === 'mint' ? o.currency ?? m.currencyKey : m.pool.get(o.pool)?.currency;
|
|
401
|
+
rest.push(`mint:${c}`, ...(o.gives === 'mint' ? [] : [`pda:pool/${o.pool}`]), `ata:player/${c}`);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const ata = `ata:player/${currency}`;
|
|
405
|
+
const wallet = [{ program: 'compute-budget', accounts: [], data: 5 }, { program: 'compute-budget', accounts: [], data: 9 }];
|
|
406
|
+
const create = { program: 'associated-token', accounts: ['wallet:player', ata, 'wallet:player', `mint:${currency}`, 'program:system', 'program:token'], data: 1 };
|
|
407
|
+
const data = tagBytes('buy') + 8 + (chosenItem || (b.split ?? []).some((s) => s.to === 'chosen') ? 33 : 1) + 4 + 8 + 4;
|
|
408
|
+
return measured({ signers: ['wallet:player'], instructions: [...wallet, create, { program: 'loot', accounts: [...own, ...rest], data }] }, detail);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------- the checker
|
|
412
|
+
|
|
413
|
+
/** The address a plan stands in for the owner's wallet before one connects (plan.mjs `placeholderContext`). Nobody
|
|
414
|
+
* holds its key, and the approval page calls it "your wallet" until a wallet connects: no spec may name it. */
|
|
415
|
+
export const STAND_IN_OWNER = encodeBase58(sha256(utf8('loot-plan:owner')));
|
|
416
|
+
|
|
417
|
+
/** The longest a contest's result or a lottery's draw may be off, or an auction's end (contest.rs, lottery.rs, auction.rs). */
|
|
418
|
+
const MAX_CONTEST_SECONDS = Number(IDL.constants.find((c) => c.name === 'MAX_CONTEST_SECONDS')?.value ?? 157_680_000);
|
|
419
|
+
/** A sealed schedule's last run comes within this of going live (schedule.rs). */
|
|
420
|
+
const MAX_SCHEDULE_SECONDS = Number(IDL.constants.find((c) => c.name === 'MAX_SCHEDULE_SECONDS')?.value ?? 315_360_000);
|
|
421
|
+
/** The most runs a sealed schedule makes (schedule.rs). */
|
|
422
|
+
/** The most places a run of a lottery's places given one by one holds (lottery.rs). */
|
|
423
|
+
const MAX_RUN_PLACES = Number(IDL.constants.find((c) => c.name === 'MAX_RUN_PLACES')?.value ?? 4_096);
|
|
424
|
+
const MAX_SEALED_RUNS = Number(IDL.constants.find((c) => c.name === 'MAX_SEALED_RUNS')?.value ?? 10_000);
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Checks an economy spec. `now` (Unix seconds) dates "publish".
|
|
428
|
+
* Returns { ok, refusals: [{ code, where, message, fix }], warnings: [{ code, where, message, fix? }] }.
|
|
429
|
+
*/
|
|
430
|
+
export function check(economy, { network = 'devnet', now = Math.floor(Date.now() / 1000) } = {}) {
|
|
431
|
+
const refusals = [];
|
|
432
|
+
const warnings = [];
|
|
433
|
+
const refuse = (code, where, message, fix) => refusals.push({ code, where, message, fix });
|
|
434
|
+
const warn = (code, where, message, fix) => warnings.push({ code, where, message, ...(fix ? { fix } : {}) });
|
|
435
|
+
const result = () => ({ ok: refusals.length === 0, refusals, warnings });
|
|
436
|
+
|
|
437
|
+
for (const where of findSecrets(economy))
|
|
438
|
+
refuse('E-SECRET', where, 'This looks like a private key, a seed phrase or a keypair file. Anything in the economy is public: whoever reads it could take the wallet.', 'Delete it now and move the funds of that wallet to a new one. A spec only ever holds public addresses.');
|
|
439
|
+
if (JSON.stringify(economy ?? null, (_, v) => (typeof v === 'bigint' ? String(v) : v)).includes(STAND_IN_OWNER))
|
|
440
|
+
refuse('E-STAND-IN', 'economy', `The economy names ${STAND_IN_OWNER}, the address the setup plan stands in for your wallet before one connects. Nobody holds its key: whatever is sent there is lost.`, 'Name a real wallet there.');
|
|
441
|
+
if (network !== 'devnet' || (economy?.network && economy.network !== 'devnet'))
|
|
442
|
+
refuse('E-NETWORK', 'economy.network', `The economy names the network “${economy?.network ?? network}”. loot runs on devnet only: mainnet waits for an audit.`, 'Set the network to "devnet".');
|
|
443
|
+
const oldVersion = economy?.version === 1;
|
|
444
|
+
if (oldVersion) refuse('E-VERSION', 'economy.version', 'Only the current version 2 economy format is supported.', 'Write the economy using the current schema.');
|
|
445
|
+
// A roll no longer burns the payment for one outcome (loot 0.23): said in words, not again by the schema.
|
|
446
|
+
const burnsGone = new Set();
|
|
447
|
+
(Array.isArray(economy?.boxes) ? economy.boxes : []).forEach((b, i) => (Array.isArray(b?.outcomes) ? b.outcomes : []).forEach((o, j) => {
|
|
448
|
+
const at = `economy.boxes[${i}].outcomes[${j}]`;
|
|
449
|
+
if (o?.gives !== 'burn' && o?.burns === undefined) return;
|
|
450
|
+
burnsGone.add(o?.gives === 'burn' ? `${at}.gives` : `${at}.burns`);
|
|
451
|
+
refuse('E-ODDS', at, `An outcome of “${b?.name ?? b?.key}” burns the payment: a box's outcome no longer does (BadEntries). A box burns its price, whatever its roll gives, with a "burn" share in its split.`, o?.gives === 'burn' ? 'Make this outcome "nothing", and give the box a "burn" share in its "split".' : 'Leave "burns" out, and give the box a "burn" share in its "split".');
|
|
452
|
+
}));
|
|
453
|
+
// A share of a pool as a prize is gone (loot 0.24): said in words, not again by the schema.
|
|
454
|
+
const shares = sharePrizes(economy);
|
|
455
|
+
for (const at of shares) refuse('E-SHARE-PRIZE', at, `${at} gives a share of a pool as a prize, and the program has no such prize on any route: a prize is what its record says, a fixed amount, all of it or none (BadEntries).`, 'Give a fixed amount: in a box { "gives": "pay", "pool": "<key>", "amount": n }, anywhere else { "gives": "tokens", "from": "pool:<key>", "amount": n }. For a jackpot that grows, use a lottery: its pot is what its tickets fill, and its places take percents of it.');
|
|
456
|
+
const aboutShare = (path) => shares.some((at) => path === at || path.startsWith(`${at}.`) || at.startsWith(`${path}.`) || at.startsWith(`${path}[`));
|
|
457
|
+
// An old version is said once, by E-VERSION, not again by the schema.
|
|
458
|
+
for (const e of validate(economy)) if (!(oldVersion && e.path === 'economy.version') && !burnsGone.has(e.path) && !aboutShare(e.path)) refuse('E-SCHEMA', e.path, `${e.path} ${e.message}.`, 'Fix that field (xsolana_guide lists what each part takes).');
|
|
459
|
+
if (refusals.length) return result();
|
|
460
|
+
|
|
461
|
+
const m = model(economy, { now });
|
|
462
|
+
const units = (amount, token, where, what = 'This amount') => {
|
|
463
|
+
if (!token) return null;
|
|
464
|
+
try {
|
|
465
|
+
return toUnits(amount ?? 0, token.decimals ?? 0);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
refuse(err.code ?? 'E-AMOUNT', where, `${what}: ${err.message}`, err.fix ?? 'Fix the amount.');
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
const seconds = (duration, where) => {
|
|
472
|
+
try {
|
|
473
|
+
return toSeconds(duration);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
refuse(err.code, where, err.message, err.fix);
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
/**
|
|
480
|
+
* A duration the program keeps in 4 bytes (a lock, an unbonding, a collectible's life, a fall's step, a wallet's
|
|
481
|
+
* window, a raise or a shift): its seconds, refused in words past what fits. A signed one (a raise's or a shift's
|
|
482
|
+
* `by`, which may be "-1d") holds half as much each way.
|
|
483
|
+
*/
|
|
484
|
+
const fourByteSeconds = (duration, where, { signed = false } = {}) => {
|
|
485
|
+
if (duration === undefined) return 0;
|
|
486
|
+
const negative = typeof duration === 'string' && duration.startsWith('-');
|
|
487
|
+
const s = seconds(negative ? duration.slice(1) : duration, where) * (negative ? -1 : 1);
|
|
488
|
+
const most = signed ? 2_147_483_647 : 4_294_967_295;
|
|
489
|
+
if (s > most || s < (signed ? -most - 1 : 0))
|
|
490
|
+
refuse('E-TIME', where, `${duration} is ${Math.abs(s).toLocaleString('en-US')} seconds, more than the program keeps here: at most ${most.toLocaleString('en-US')} (about ${Math.floor(most / 31_557_600)} years)${signed ? ' each way' : ''}.`, 'Use a shorter time.');
|
|
491
|
+
return s;
|
|
492
|
+
};
|
|
493
|
+
/**
|
|
494
|
+
* A duration counted from go-live into a time the program keeps (a farm's end, a schedule's next run): its seconds,
|
|
495
|
+
* refused in words when go-live plus it is past what a count of seconds holds exactly.
|
|
496
|
+
*/
|
|
497
|
+
const fromNowSeconds = (duration, where) => {
|
|
498
|
+
const s = seconds(duration, where);
|
|
499
|
+
if (now + s > Number.MAX_SAFE_INTEGER) refuse('E-TIME', where, `${duration} from go-live is further off than the program can count.`, 'Use a shorter time.');
|
|
500
|
+
return s;
|
|
501
|
+
};
|
|
502
|
+
/** Refuses a list over one of the program's caps (`LIMITS`), which it would refuse when the owner signs. */
|
|
503
|
+
const tooMany = (count, most, code, where, what, error, fix) => {
|
|
504
|
+
if (count > most) refuse(code, where, `${what}; the program takes at most ${most} (${error}).`, fix);
|
|
505
|
+
};
|
|
506
|
+
const percent = (value, where) => {
|
|
507
|
+
if (value === undefined) return 0;
|
|
508
|
+
const bps = toBps(value);
|
|
509
|
+
if (bps === null) refuse('E-PERCENT', where, `${value}% is finer than the program counts (hundredths of a percent).`, 'Round it to two decimals.');
|
|
510
|
+
return bps ?? 0;
|
|
511
|
+
};
|
|
512
|
+
/** A boost's percent (up to 10,000%, the schema's bound): whole hundredths of a percent, as the program counts it. */
|
|
513
|
+
const boostPercent = (value, where) => {
|
|
514
|
+
if (value === undefined) return;
|
|
515
|
+
if (Math.abs(Math.round(Number(value) * 100) - Number(value) * 100) >= 1e-6) refuse('E-PERCENT', where, `${value}% is finer than the program counts (hundredths of a percent).`, 'Round it to two decimals.');
|
|
516
|
+
};
|
|
517
|
+
/** An amount checked without a refusal of its own (another check refuses it where it is written): its base units, or null. */
|
|
518
|
+
const quietUnits = (amount, token) => {
|
|
519
|
+
try {
|
|
520
|
+
return toUnits(amount ?? 0, token?.decimals ?? 0);
|
|
521
|
+
} catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// ---- keys and ids
|
|
527
|
+
for (const [list, kind] of Object.entries({ tokens: 'token', items: 'item', pools: 'pool', stashes: 'stash', boxes: 'box', payments: 'payment', recipes: 'recipe', schedules: 'schedule', lotteries: 'lottery', farms: 'farm', contests: 'contest', auctions: 'auction', holds: 'hold', grants: 'grant', decisions: 'decision' })) {
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
for (const [i, part] of (economy[list] ?? []).entries()) {
|
|
530
|
+
if (seen.has(part.key)) refuse('E-KEY-DUP', `economy.${list}[${i}].key`, `Two ${list} are called “${part.key}”: the other parts could not tell which one they mean.`, `Give each ${kind} its own key.`);
|
|
531
|
+
seen.add(part.key);
|
|
532
|
+
}
|
|
533
|
+
const field = list === 'pools' ? 'index' : 'id';
|
|
534
|
+
const ids = new Set();
|
|
535
|
+
for (const [i, part] of (economy[list] ?? []).entries()) {
|
|
536
|
+
if (part[field] === undefined) continue;
|
|
537
|
+
if (ids.has(part[field])) refuse('E-KEY-DUP', `economy.${list}[${i}].${field}`, `Two ${list} have the program number ${part[field]}: the second would overwrite the first.`, `Remove the ${field} from the newer one; it gets a free number when it goes live.`);
|
|
538
|
+
ids.add(part[field]);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const boxIds = new Map();
|
|
542
|
+
for (const b of m.allBoxes) {
|
|
543
|
+
if (boxIds.has(b.id)) refuse('E-KEY-DUP', b.shopOf ? `economy.items.${b.shopOf}.shopBox` : `economy.boxes.${b.key}.id`, `The box number ${b.id} is taken twice (a box and a shop listing share the numbers).`, 'Remove one of the numbers; it gets a free one when it goes live.');
|
|
544
|
+
boxIds.set(b.id, b);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---- tokens and the game's currency
|
|
548
|
+
const sols = (economy.tokens ?? []).filter((t) => t.source === 'sol');
|
|
549
|
+
if (sols.length > 1) refuse('E-TOKEN', 'economy.tokens', 'SOL is listed more than once.', 'Keep one token with source "sol".');
|
|
550
|
+
for (const [i, t] of (economy.tokens ?? []).entries()) {
|
|
551
|
+
const where = `economy.tokens[${i}]`;
|
|
552
|
+
const tok = m.tokens.get(t.key);
|
|
553
|
+
// The source as the model reads it: "project", and "game", its older spelling, are both "game" there.
|
|
554
|
+
const source = tok.source;
|
|
555
|
+
if (source === 'sol' && t.mint && !tok.wsol) refuse('E-TOKEN', `${where}.mint`, `“${t.key}” is SOL, but names another mint.`, 'Remove the mint: SOL needs none.');
|
|
556
|
+
if (source === 'existing' && (!t.mint || t.decimals === undefined))
|
|
557
|
+
refuse('E-TOKEN', where, `“${t.key}” is an existing token, so the economy needs its mint address and its decimals.`, 'Add "mint" (the token’s address) and "decimals", read from the mint (an explorer shows them): `xsolana approve` reads the mint and refuses decimals that differ.');
|
|
558
|
+
if (source === 'game' && t.program === 'token-2022')
|
|
559
|
+
refuse('E-NOT-YET', `${where}.program`, `“${t.key}” is a token the project makes itself, and the setup makes those as classic SPL tokens.`, 'Remove "program", or use an existing Token-2022 mint (source "existing").');
|
|
560
|
+
// A token the project makes gets its mint when the setup makes it (kept in "live.mints"): one it names already
|
|
561
|
+
// would be minted from without ever being made.
|
|
562
|
+
if (source === 'game' && t.mint)
|
|
563
|
+
refuse('E-TOKEN', `${where}.mint`, `“${t.key}” is a token the project makes when the economy goes live, but it names the mint ${t.mint}: the setup would never make it, and would mint from an address the project has no say over.`, 'Remove "mint" for a token the setup makes, or make it an existing token (source "existing", with its "decimals").');
|
|
564
|
+
if (t.supply !== undefined && source !== 'game') refuse('E-TOKEN', `${where}.supply`, `“${t.key}” is not made by the project, so its supply is whatever its mint says.`, 'Remove "supply", or make it a token the project makes (source "project").');
|
|
565
|
+
if (source === 'game' && t.supply !== undefined) {
|
|
566
|
+
const supply = units(t.supply, tok, `${where}.supply`, `“${t.key}”’s supply`);
|
|
567
|
+
if (supply === 0n) refuse('E-TOKEN', `${where}.supply`, `“${t.key}” has a supply of 0.`, 'Give it a supply above 0, or leave "supply" out for a token the game mints.');
|
|
568
|
+
// A seed that is not an amount is refused with its pool, below.
|
|
569
|
+
const seeded = m.pools.filter((p) => p.currency === t.key).reduce((n, p) => n + (quietUnits(p.seed, tok) ?? 0n), 0n);
|
|
570
|
+
if (supply !== null && seeded > supply) refuse('E-TOKEN', `${where}.supply`, `The pools of “${t.key}” are seeded with more than its whole supply of ${t.supply}.`, 'Seed them with less, or raise the supply.');
|
|
571
|
+
}
|
|
572
|
+
const p = t.powers ?? {};
|
|
573
|
+
percent(p.transferFeePercent, `${where}.powers.transferFeePercent`);
|
|
574
|
+
if (p.nonTransferable) refuse('E-TOKEN', `${where}.powers`, `“${t.key}” cannot be transferred, so it cannot pay for anything, fill a pool or be staked.`, 'Use another token.');
|
|
575
|
+
const powers = [
|
|
576
|
+
p.freeze && 'freeze any account holding it, including the game’s pools and the vault that holds open orders and bids (a frozen vault holds them until it thaws)',
|
|
577
|
+
p.permanentDelegate && 'move or burn it out of any account, the game’s pools included',
|
|
578
|
+
p.transferHook && 'run its own program on every transfer, which can refuse one (a settle that pays a price or its shares in it waits until the hook allows it; a box that rolls, or that a recipe, a mission or a prize rolls, can’t give it as a prize)',
|
|
579
|
+
p.transferFeePercent > 0 && `keep ${p.transferFeePercent}% of every transfer (pools and prizes receive that much less)`,
|
|
580
|
+
p.pausable && 'pause every transfer of it',
|
|
581
|
+
p.defaultFrozen && 'start every new account frozen',
|
|
582
|
+
!p.defaultFrozen && p.defaultState && p.freeze && 'switch every new account to start frozen',
|
|
583
|
+
p.mintAuthority === 'someone else' && 'mint more of it whenever its owner wants',
|
|
584
|
+
].filter(Boolean);
|
|
585
|
+
if (powers.length) warn('W-MINT-POWERS', `${where}.powers`, `Whoever controls the mint of “${t.key}” can ${powers.join('; ')}. The program treats that as the token’s own business: players should know.`, 'Use a mint without those powers, or say so where players buy.');
|
|
586
|
+
if (source === 'existing' && !t.powers)
|
|
587
|
+
warn('W-MINT-POWERS', where, `The checker cannot read the mint of “${t.key}” from here, so it does not know what its owner can do (freeze, a permanent delegate, a transfer hook, which no rolled box can give a prize in, or fee).`, 'Look the mint up on an explorer and list what it can do under "powers".');
|
|
588
|
+
}
|
|
589
|
+
if (!m.currency) {
|
|
590
|
+
if (m.currencyKey) refuse('E-REF', 'economy.currency', `The game’s currency “${m.currencyKey}” is not in the tokens.`, 'Add it to "tokens", or name one that is there.');
|
|
591
|
+
else refuse('E-CURRENCY', 'economy.currency', 'The economy has no currency: the program needs one for the shop’s prices and the treasury.', 'Add a token (for example { "key": "sol", "source": "sol" }) and name it in "currency".');
|
|
592
|
+
return result();
|
|
593
|
+
}
|
|
594
|
+
const game = m.currency;
|
|
595
|
+
if (economy?.treasury !== undefined && (!m.treasuryWallet || !isAddress(m.treasuryWallet)))
|
|
596
|
+
refuse('E-REF', 'economy.treasury', `The treasury “${economy.treasury}” is not "wallet:<address>".`, 'Write "wallet:<the wallet’s public address>", or leave it out for the owner’s own wallet.');
|
|
597
|
+
|
|
598
|
+
const token = (key, where) => {
|
|
599
|
+
const t = m.tokens.get(key ?? m.currencyKey);
|
|
600
|
+
if (!t) refuse('E-REF', where, `There is no token “${key}”.`, 'Add it to "tokens", or name one that is there.');
|
|
601
|
+
return t;
|
|
602
|
+
};
|
|
603
|
+
const pool = (key, where) => {
|
|
604
|
+
const p = m.pool.get(key);
|
|
605
|
+
if (!p) refuse('E-REF', where, `There is no pool “${key}”.`, 'Add it to "pools", or name one that is there.');
|
|
606
|
+
return p;
|
|
607
|
+
};
|
|
608
|
+
const item = (key, where) => {
|
|
609
|
+
const it = m.item.get(key);
|
|
610
|
+
if (!it) refuse('E-REF', where, `There is no item “${key}”.`, 'Add it to "items", or name one that is there.');
|
|
611
|
+
return it;
|
|
612
|
+
};
|
|
613
|
+
/** A box a prize rolls (`order::prize_roll`): one of this spec's. On chain a lottery's, a contest's or an auction's roll of no box rolls nothing, and a grant's is refused. */
|
|
614
|
+
const prizeBox = (key, where, what, code) => {
|
|
615
|
+
const b = m.box.get(key);
|
|
616
|
+
if (!b) refuse(code, where, `${what} gives a roll of “${key}”, which is no box: on chain ${code === 'E-GRANT' ? 'every claim would be refused' : code === 'E-AUCTION' ? 'the auction could not open (LotRefused)' : 'its winner would get no roll'}.`, 'Name a box of this spec.');
|
|
617
|
+
return b;
|
|
618
|
+
};
|
|
619
|
+
/**
|
|
620
|
+
* A compressed NFT of an item (prize.rs `give_compressed`): a collectible, not soulbound, whose link fits Bubblegum's
|
|
621
|
+
* 200 bytes with its serial in, in a collection that takes compressed NFTs. The setup's own does when it is made
|
|
622
|
+
* with a compressed prize in the spec; one made before never can.
|
|
623
|
+
*/
|
|
624
|
+
const compressedCheck = (it, at, what, code) => {
|
|
625
|
+
// The item records whether its collection carries the plugin (`Item::compressed`): without it, a box's open is
|
|
626
|
+
// refused (SoldOut), a sale, a claim or a craft too (PrizeNotCovered), and a finishing route gives nothing.
|
|
627
|
+
const never = code === 'E-GRANT' || code === 'E-RECIPE' ? 'every claim or craft would be refused (PrizeNotCovered)' : code === 'E-ODDS' ? 'opening the box would be refused (SoldOut), and buying it (PrizeNotCovered)' : 'its winner would get nothing';
|
|
628
|
+
if (!isCollectible(it)) return refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but it is a normal item: only a collectible is an NFT.`, 'Make it a collectible, or leave "compressed" out.');
|
|
629
|
+
if (it.soulbound) return refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but it is soulbound: a compressed NFT can’t be kept from moving, so the program never gives one (${never}).`, 'Give it as a Core collectible (leave "compressed" out), or make the item tradable.');
|
|
630
|
+
// Its serial at its longest, and each drawn trait's value at its longest (32 bytes) wherever its `{attribute}` is.
|
|
631
|
+
const longest = (it.drawn ?? []).reduce((uri, d) => uri.replaceAll(`{${d.attribute}}`, 'x'.repeat(32)), (it.uri ?? '').replaceAll('{serial}', String(Number(it.supply ?? 0) || 4_294_967_295)));
|
|
632
|
+
if (utf8Bytes(longest) > LIMITS.compressedUriBytes) refuse(code, `${at}.compressed`, `${label(it)}’s link, its serial written in, is longer than Bubblegum’s ${LIMITS.compressedUriBytes} bytes: ${never}.`, 'Use a shorter link.');
|
|
633
|
+
if (it.collection) warn('W-COMPRESSED', `${at}.compressed`, `${label(it)} is minted into ${it.collection}: a compressed one is given only if that collection was made with Bubblegum’s plugin (BubblegumV2), which a collection can’t add later; otherwise ${never}.`, 'Use the setup’s collection, or one made with the plugin.');
|
|
634
|
+
// A new setup still landing made its collection with the plugin, and opens the tree after the project.
|
|
635
|
+
else if (m.live.project && !m.live.tree && !(m.live.partial && !m.live.published)) refuse(code, `${at}.compressed`, `${what} gives ${label(it)} compressed, but the project’s collection went live without Bubblegum’s plugin, which a collection can’t add later: ${never}.`, 'Give it as a Core collectible (leave "compressed" out), or mint it into another collection made with the plugin ("collection" on the item).');
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* A prize (prize.rs, `model.prizeLine`): what it names is this spec's, and it gives something. `code` is the route's
|
|
639
|
+
* refusal code. Returns the prize line, or null.
|
|
640
|
+
*/
|
|
641
|
+
const prizeCheck = (given, at, what, code) => {
|
|
642
|
+
const line = prizeLine(given);
|
|
643
|
+
if (!line) {
|
|
644
|
+
refuse(code, at, `${what} gives a prize this spec can’t read.`, 'Write a prize line: { "gives": "item", "item": key }, { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" }, { "gives": "nft", "stash": key, "nft": address } or { "gives": "roll", "box": key }.');
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
if (line.split && (code !== 'E-CONTEST' || line.gives !== 'tokens'))
|
|
648
|
+
refuse(code, `${at}.split`, `${what} splits its prize by stake: only a contest's prize of tokens can be split (BadContest).`, 'Leave "split" out.');
|
|
649
|
+
if (line.gives === 'item') {
|
|
650
|
+
const it = line.item ? item(line.item, `${at}.item`) : refuse(code, at, `${what} gives an item but does not say which.`, 'Add "item".');
|
|
651
|
+
if (it && it.active === false) warn('W-PRIZE', `${at}.item`, `${what} gives ${label(it)}, which is switched off: no prize gives an item switched off, so ${code === 'E-GRANT' || code === 'E-RECIPE' ? 'the claim or craft is refused' : 'its winner gets nothing'} until it is switched on.`, `Switch ${label(it)} on.`);
|
|
652
|
+
if (it && line.compressed) compressedCheck(it, at, what, code);
|
|
653
|
+
} else if (line.compressed) {
|
|
654
|
+
refuse(code, `${at}.compressed`, `${what} gives ${line.gives} compressed: only an item’s collectible can be given as a compressed NFT.`, 'Leave "compressed" out.');
|
|
655
|
+
} else if (line.gives === 'tokens') {
|
|
656
|
+
const minted = line.from === 'mint';
|
|
657
|
+
const poolKey = prizePool(line);
|
|
658
|
+
if (!minted && !poolKey) refuse(code, `${at}.from`, `${what} gives tokens but does not say where from.`, 'Add "from": "mint" (the game mints them) or "pool:<key>".');
|
|
659
|
+
const p = poolKey ? pool(poolKey, `${at}.from`) : null;
|
|
660
|
+
const t = minted ? token(line.currency, `${at}.currency`) : p ? m.tokens.get(p.currency) : null;
|
|
661
|
+
if (minted && t && !t.gameMints) refuse('E-MINT-NOT-OURS', `${at}.currency`, `${what} mints ${t.symbol}, which the project cannot mint.`, 'Pay it from a pool ("from": "pool:<key>"), or use a token with source "project".');
|
|
662
|
+
if (p && line.currency && line.currency !== p.currency) refuse('E-POOL-MINT', `${at}.from`, `${what} gives ${m.tokens.get(line.currency)?.symbol ?? line.currency} from “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`, `Leave "currency" out: a pool’s prize is in what it holds.`);
|
|
663
|
+
if (t && units(line.amount, t, `${at}.amount`, `What ${what} gives`) === 0n) refuse(code, `${at}.amount`, `${what} gives an amount of 0.`, 'Give it an amount above 0.');
|
|
664
|
+
} else if (line.gives === 'nft' && line.held) {
|
|
665
|
+
// An NFT a lottery's place holds in the lottery's own stash: a lottery's place only (prize.rs Prize::Held).
|
|
666
|
+
if (code !== 'E-LOTTERY') refuse(code, `${at}.held`, `${what} gives a held NFT: only a lottery's place holds one, in its own stash (BadPrize).`, 'Put it in a stash and give it with "stash", or raffle it in a lottery.');
|
|
667
|
+
if (line.stash) refuse(code, `${at}.stash`, `${what} holds its NFT in the lottery's own stash, not in “${line.stash}”.`, 'Leave "stash" out.');
|
|
668
|
+
if (!isAddress(line.nft)) refuse(code, `${at}.nft`, `${what} gives the NFT “${line.nft}”, which is not an address.`, 'Write the NFT’s address (a Core asset).');
|
|
669
|
+
} else if (line.gives === 'nft') {
|
|
670
|
+
const st = line.stash ? m.stash.get(line.stash) : null;
|
|
671
|
+
if (!line.stash) refuse(code, at, `${what} gives an NFT but does not say which stash holds it.`, 'Add "stash": the stash that lists it.');
|
|
672
|
+
else if (!st) refuse('E-REF', `${at}.stash`, `There is no stash “${line.stash}”.`, 'Add it to "stashes", or name one that is there.');
|
|
673
|
+
if (!isAddress(line.nft)) refuse(code, `${at}.nft`, `${what} gives the NFT “${line.nft}”, which is not an address.`, 'Write the NFT’s address (a Core asset, or a Token Metadata NFT’s mint).');
|
|
674
|
+
else if (st && !stashNfts(st).some((n) => n.asset === line.nft))
|
|
675
|
+
refuse(code, `${at}.nft`, `${what} gives the NFT ${line.nft}, which ${label(st)} does not list: a prize gives only an NFT a stash of the game holds, so it would never be given.`, `List it in ${label(st)}'s "nfts", which puts it in when the economy goes live.`);
|
|
676
|
+
} else if (line.gives === 'roll') prizeBox(line.box, `${at}.box`, what, code);
|
|
677
|
+
else if (line.gives === 'raffled') {
|
|
678
|
+
if (code !== 'E-LOTTERY') refuse(code, `${at}.gives`, `${what} gives an NFT drawn from a lottery's own stash: only a lottery's place does (BadPrize).`, 'Give an NFT of a stash through a box\'s roll ("gives": "stash"), or raffle them in a lottery.');
|
|
679
|
+
} else refuse(code, `${at}.gives`, `${what} gives “${line.gives}”, which no prize is.`, 'Use "item", "tokens", "nft", "roll" or, in a lottery\'s place, "raffled".');
|
|
680
|
+
return line;
|
|
681
|
+
};
|
|
682
|
+
/**
|
|
683
|
+
* A mint whose holders can turn plain transfers away (confidential transfers): a lottery, a contest and an auction
|
|
684
|
+
* refuse it, since a bid or a payout sent back could never land. Only when the spec says so (`powers`).
|
|
685
|
+
*/
|
|
686
|
+
const confidential = (t, where, what, code) => {
|
|
687
|
+
if (t?.powers?.confidentialTransfers) refuse(code, where, `${what} is in ${t.symbol}, whose mint allows confidential transfers: any holder could turn plain transfers away, so a payout or a bid sent back might never land (the program refuses it).`, 'Use a mint without confidential transfers.');
|
|
688
|
+
};
|
|
689
|
+
/**
|
|
690
|
+
* A mint whose new accounts start frozen, or can be made to (a default state and a freeze authority, which can switch
|
|
691
|
+
* it to frozen): a lottery, a contest, an auction and a sealed schedule refuse it as they open, since each reads the
|
|
692
|
+
* setting once, and a payout or a refund opened into a frozen account could never land. Only when the spec says so.
|
|
693
|
+
*/
|
|
694
|
+
const startsFrozen = (t, where, what, code, error) => {
|
|
695
|
+
const p = t?.powers ?? {};
|
|
696
|
+
if (!p.defaultFrozen && !(p.defaultState && p.freeze)) return;
|
|
697
|
+
refuse(code, where, `${what} is in ${t.symbol}, whose new accounts ${p.defaultFrozen ? 'start frozen' : 'can be made to start frozen (its mint has a default account state and a freeze authority, which can switch it to frozen at any time)'}: a winner's, a refund's or a payout's account opened for them could never take what is owed (${error}).`, 'Use a mint without a default account state, or one with no freeze authority.');
|
|
698
|
+
};
|
|
699
|
+
/** Whether `address` is the game's own vault for `t` (where open orders, prizes set aside and bids wait), when both are known. */
|
|
700
|
+
const gameVault = (address, t) => Boolean(m.live.project && t?.mint && !t.mint.startsWith('@') && [TOKEN_PROGRAM, TOKEN_2022_PROGRAM].some((program) => vault(m.live.project, t.mint, program) === address));
|
|
701
|
+
/** Whether anything puts money into lottery `l`'s pot: its seed, a place's prize, or a share or schedule that names "lottery:<key>". */
|
|
702
|
+
const lotteryPotFilled = (l) => {
|
|
703
|
+
const to = `lottery:${l.key}`;
|
|
704
|
+
if (Number(l.seed ?? 0) > 0 || (l.places ?? []).some((p) => typeof p === 'object' && p.prize)) return true;
|
|
705
|
+
const splits = [...m.allBoxes, ...m.payments, ...m.lotteries.map((x) => ({ split: lotterySplit(x) })), ...m.contests, ...m.auctions].map((x) => x.split ?? []);
|
|
706
|
+
if (splits.some((sp) => sp.some((share) => share.to === to))) return true;
|
|
707
|
+
if (m.schedules.some((sc) => sc.to === to)) return true;
|
|
708
|
+
return m.recipes.some((r) => (r.in ?? []).some((i) => i.to === to));
|
|
709
|
+
};
|
|
710
|
+
/**
|
|
711
|
+
* A route between players the project's keys have no say over (an auction's bids of NFTs, an offer's rule) reads an
|
|
712
|
+
* NFT outside the project's collections without its Sheet: a Core NFT by its own attributes, a Token Metadata or
|
|
713
|
+
* compressed NFT with none, so an attribute test never passes for those there (rules.rs `read_unwritten`).
|
|
714
|
+
*/
|
|
715
|
+
const outsideAttribute = (r, where, what) => {
|
|
716
|
+
if (!r?.collection || !r.attribute || m.collections.includes(r.collection)) return;
|
|
717
|
+
warn('W-RULE-OUTSIDE', where, `${what} test “${r.attribute}” of NFTs of ${r.collection}, outside the project’s collections: there it reads a Core NFT’s own attributes, never what the project wrote in its Sheet, so the test fails for an NFT that doesn’t carry “${r.attribute}” itself (every Token Metadata or compressed NFT).`, 'Test only the collection, or use Core NFTs that carry the attribute themselves.');
|
|
718
|
+
};
|
|
719
|
+
/** Whether the words on a Token Metadata print were said (once a spec). */
|
|
720
|
+
let printsSaid = false;
|
|
721
|
+
/** A rule on what an NFT says (rules.rs): an item of the game's must be a collectible; or a collection is named. */
|
|
722
|
+
const rule = (r, where, what) => {
|
|
723
|
+
if (!r) return;
|
|
724
|
+
// A print of a Token Metadata master carries its master's collection, but its master's holder makes it without the
|
|
725
|
+
// collection's authority: the program reads it as in no collection (metadata.rs).
|
|
726
|
+
if (r.collection && isAddress(r.collection) && !m.collections.includes(r.collection) && !printsSaid) {
|
|
727
|
+
printsSaid = true;
|
|
728
|
+
warn('W-RULE-PRINTS', `${where}.collection`, `${what} names the collection ${r.collection}: if it is a Token Metadata collection, a print of one of its NFTs (an edition of a master) doesn’t count, since whoever holds the master makes prints without the collection’s authority; the program reads a print as in no collection. This holds for every rule on a collection in the spec.`, 'Nothing to change: tell holders of prints that only the original NFTs count.');
|
|
729
|
+
}
|
|
730
|
+
// A gate may list wallets instead (rules.rs Gate::Wallets): a duel's two players, a tournament's entrants.
|
|
731
|
+
// Or a list of any size (rules.rs Gate::List): its root on chain, each player's proof passed as accounts.
|
|
732
|
+
if (r.list !== undefined) {
|
|
733
|
+
r.list.forEach((w, i) => isAddress(w) || refuse('E-REF', `${where}.list[${i}]`, `${what} lists “${w}”, which is not an address.`, 'Write the wallet’s public address.'));
|
|
734
|
+
if (!refusals.some((x) => x.where.startsWith(`${where}.list`))) {
|
|
735
|
+
const { depth } = gateList(r.list);
|
|
736
|
+
if (depth > 12) warn('W-LIST', `${where}.list`, `${what}’s list is ${depth} levels deep: each player passes ${depth} proof nodes as accounts, ${depth * 32} bytes of their transaction.`, 'A shorter list, or gate by an NFT they hold.');
|
|
737
|
+
warn('W-LIST', `${where}.list`, `${what} lets in ${r.list.length} listed wallet${r.list.length === 1 ? '' : 's'}: the program keeps only the list’s root, so the app (or xsolana_play) passes each player’s proof, and the list itself must stay where the app can read it (this spec keeps it).`);
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
// Or a stake (rules.rs Gate::Staked): the wallet's token seat in a farm of tokens holds at least `atLeast`.
|
|
742
|
+
if (r.staked !== undefined) {
|
|
743
|
+
const f = m.farm.get(r.staked.farm);
|
|
744
|
+
if (!f) refuse('E-REF', `${where}.staked.farm`, `${what} asks for a stake in the farm “${r.staked.farm}”, which is not in this economy.`, 'Name one of "farms" by its key.');
|
|
745
|
+
else if (f.stakes === 'collectibles') refuse('E-RULE', `${where}.staked.farm`, `${what} asks for a stake in ${label(f)}, a farm of collectibles: a stake gate reads a wallet’s seat of tokens.`, 'Name a farm that stakes a token, or gate by a collectible they hold.');
|
|
746
|
+
else {
|
|
747
|
+
const t = m.tokens.get(f.stakes);
|
|
748
|
+
const least = units(r.staked.atLeast, t, `${where}.staked.atLeast`, `${what}’s stake`);
|
|
749
|
+
if (least === 0n) refuse('E-RULE', `${where}.staked.atLeast`, `${what} asks for a stake of 0, which every wallet has: the program refuses it (BadRule).`, 'Ask for more than 0, or leave the gate out.');
|
|
750
|
+
// A stake counts only while its tier's lock runs: one with no lock could move to another wallet and count again.
|
|
751
|
+
const tiers = f.tiers ?? [{ lock: 0 }];
|
|
752
|
+
// A tier with no lock, or one a stake can leave early for a fee, never counts (rules.rs Gate::Staked).
|
|
753
|
+
const unlocked = tiers.map((x, i) => [x, i]).filter(([x]) => seconds(x.lock ?? 0, `${where}.staked.farm`) <= 0 || Number(x.exitFee ?? 0) > 0).map(([, i]) => i + 1);
|
|
754
|
+
if (unlocked.length === tiers.length) warn('W-STAKED', `${where}.staked.farm`, `${what} asks for a stake in ${label(f)}, which has no tier that counts: a stake counts only while it is locked with no early exit (so one stake can’t be unstaked, moved to another wallet and count again), and a stake there never passes (GateNotMet).`, `Give ${label(f)} a tier with a "lock" (like "7d") and no "exitFee", or gate by something else.`);
|
|
755
|
+
else if (unlocked.length) warn('W-STAKED', `${where}.staked.farm`, `${label(f)}’s tier${unlocked.length === 1 ? '' : 's'} ${unlocked.join(', ')} ${unlocked.length === 1 ? 'has' : 'have'} no lock, so a stake in ${unlocked.length === 1 ? 'it' : 'them'} never passes ${what}’s gate: only a locked stake counts.`, 'Tell players to stake in a locked tier, or give every tier a lock.');
|
|
756
|
+
if (least !== null && least !== 0n && unlocked.length < tiers.length) warn('W-STAKED', `${where}.staked`, `${what} lets in a wallet with at least ${r.staked.atLeast} ${t?.symbol ?? f.stakes} staked in ${label(f)} while its lock runs, staked before the second it is used (so it can’t be staked, used and unstaked in one transaction, and one stake counts for one wallet a lock). The app (or xsolana_play) passes the wallet’s seat and the farm; staking more counts at once, and the gate shuts once the lock ends (staking again starts a new lock).`);
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (r.wallets !== undefined) {
|
|
761
|
+
if (!r.wallets.length) warn('W-GATE', `${where}.wallets`, `${what} lists no wallets: nobody passes it.`, 'List the wallets it lets in, or leave the gate out.');
|
|
762
|
+
r.wallets.forEach((w, i) => isAddress(w) || refuse('E-REF', `${where}.wallets[${i}]`, `${what} lists “${w}”, which is not an address.`, 'Write the wallet’s public address.'));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if ((r.atLeast !== undefined || r.equals !== undefined || r.time !== undefined) && !r.attribute) refuse('E-RULE', where, `${what} tests a number, a text or a time but names no attribute.`, 'Add "attribute", or leave the test out to ask only for the item.');
|
|
766
|
+
if (r.time !== undefined && (r.atLeast !== undefined || r.equals !== undefined)) refuse('E-RULE', where, `${what} asks for a time and for a number or a text at once.`, 'Keep one of "time", "atLeast" and "equals".');
|
|
767
|
+
if (!r.item && !r.collection && !r.attribute) refuse('E-RULE', where, `${what} asks for nothing: no item, no collection and no attribute.`, 'Name the item or the collection players must hold, an attribute, or both.');
|
|
768
|
+
if (r.item && r.collection) refuse('E-RULE', where, `${what} names both an item and a collection: a rule is about one.`, 'Keep "item" for an item the game mints, or "collection" for any NFT of a collection.');
|
|
769
|
+
if (r.collection && !isAddress(r.collection)) refuse('E-REF', `${where}.collection`, `${what} names the collection “${r.collection}”, which is not an address.`, 'Write the Core collection’s address, or the Token Metadata collection mint.');
|
|
770
|
+
if (r.atLeast !== undefined && r.equals !== undefined) refuse('E-RULE', where, `${what} asks for an attribute that is both at least a number and equal to a text.`, 'Keep one of "atLeast" and "equals".');
|
|
771
|
+
if (r.item) {
|
|
772
|
+
const it = item(r.item, `${where}.item`);
|
|
773
|
+
if (it && !isCollectible(it)) refuse('E-RULE', `${where}.item`, `${what} asks players to show ${label(it)}, a normal item: only collectibles can be shown.`, `Make ${label(it)} a collectible.`);
|
|
774
|
+
}
|
|
775
|
+
if (RESERVED_ATTRIBUTES.includes(r.attribute) && !['rarity', 'serial', 'made', 'used'].includes(r.attribute))
|
|
776
|
+
warn('W-RULE', `${where}.attribute`, `${what} reads “${r.attribute}”, which only the program writes.`);
|
|
777
|
+
};
|
|
778
|
+
/** A raise or a shift that may move a time (`fromNow`): durations only with it, and a key a farm keeps a time under moved only with it. */
|
|
779
|
+
const timed = (x, where, what, code) => {
|
|
780
|
+
const durations = typeof x.by === 'string' || typeof x.upTo === 'string';
|
|
781
|
+
const key = x.key ?? x.attribute;
|
|
782
|
+
const kept = m.farms.find((f) => f.lockAttribute === key || f.upkeepAttribute === key);
|
|
783
|
+
if (durations && !x.fromNow && !kept) refuse(code, where, `${what} by a duration, but it is not a time.`, 'Add "fromNow": true to move a time from now, or write plain numbers.');
|
|
784
|
+
if (typeof x.upTo === 'string' && !x.fromNow) refuse(code, `${where}.upTo`, `${what} up to ${x.upTo}, a time from now, but it does not count from now.`, 'Add "fromNow": true, or leave "upTo" out.');
|
|
785
|
+
if (kept && !x.fromNow && !durations) warn('W-TIME', where, `${what}, which ${label(kept)} keeps as a time (Unix seconds), by a plain number: ${toBy(x.by)} seconds.`, 'Write "by" as a duration ("1d"), with "fromNow": true to count from now.');
|
|
786
|
+
};
|
|
787
|
+
const effect = (e, where, what) => {
|
|
788
|
+
if (!e) return;
|
|
789
|
+
if (e.addPercent === undefined && e.perPointPercent === undefined) refuse('E-RULE', where, `${what} adds nothing.`, 'Give it "addPercent", or "perPointPercent" (with "upTo").');
|
|
790
|
+
if (e.addPercent !== undefined && e.perPointPercent !== undefined) refuse('E-RULE', where, `${what} has both a flat boost and a boost per point.`, 'Keep one.');
|
|
791
|
+
boostPercent(e.addPercent, `${where}.addPercent`);
|
|
792
|
+
boostPercent(e.perPointPercent, `${where}.perPointPercent`);
|
|
793
|
+
};
|
|
794
|
+
/** Where an NFT a box rolls on (or takes as its price) goes: an address to give it to, or one of the spec's stashes. */
|
|
795
|
+
const thenCheck = (then, where, what) => {
|
|
796
|
+
if (then?.giveTo && !isAddress(then.giveTo)) refuse('E-ROLL', `${where}.giveTo`, `${what} gives the NFT to “${then.giveTo}”, which is not an address.`, 'Write a wallet address.');
|
|
797
|
+
if (then?.stash && !m.stash.get(then.stash)) refuse('E-REF', `${where}.stash`, `There is no stash “${then.stash}” for ${what} to put the NFT in.`, 'Add it to "stashes", or name one that is there.');
|
|
798
|
+
};
|
|
799
|
+
for (const p of m.pools) if (!m.tokens.has(p.currency)) refuse('E-REF', `economy.pools.${p.key}.currency`, `The pool “${p.key}” holds “${p.currency}”, which is not in the tokens.`, 'Add the token, or name one that is there.');
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* A split carried out in `tok`: percents, at most 8 shares (any number in a payment's) and 100% at most (state.rs check_later_shares), pools of the same
|
|
803
|
+
* token, and no share that burns in wrapped SOL (the program refuses it). What the shares leave goes to the
|
|
804
|
+
* treasury wallet's account of `tok`, whatever token it is. `chosen`: the buyer may name one share ("chosen",
|
|
805
|
+
* "chosen:<item>"): a box's or a lottery's split; 'wallet': only "chosen", a wallet (a payment's split).
|
|
806
|
+
*/
|
|
807
|
+
const split = (list, tok, where, { what, chosen = false }) => {
|
|
808
|
+
const shares = sharesOf(list);
|
|
809
|
+
shares.forEach((s, i) => percent(s.percent, `${where}[${i}].percent`));
|
|
810
|
+
const total = shares.reduce((n, s) => n + (s.bps ?? 0), 0);
|
|
811
|
+
if (total > 10_000) refuse('E-SPLIT', where, `${what}’s shares add up to ${total / 100}%, more than the whole payment.`, 'Make them add up to 100% or less; the treasury gets what they leave.');
|
|
812
|
+
if (shares.filter((s) => s.kind === 'chosen').length > 1) refuse('E-CHOSEN', where, `${what} lets the buyer choose more than one share.`, 'Let them choose one.');
|
|
813
|
+
// A split kept to be paid later (a box's, a lottery's, a contest's, an auction's) holds at most 8 shares; a
|
|
814
|
+
// payment's is paid at once and has no such cap.
|
|
815
|
+
if (chosen !== 'wallet' && shares.length > MAX_SHARES) refuse('E-SPLIT', where, `${what}’s split has ${shares.length} shares; the program keeps at most ${MAX_SHARES} (BadShares).`, `Merge some, to ${MAX_SHARES} or fewer.`);
|
|
816
|
+
for (const [i, s] of shares.entries()) {
|
|
817
|
+
if (s.kind === 'pool') {
|
|
818
|
+
const p = pool(s.key, `${where}[${i}].to`);
|
|
819
|
+
if (p && tok && p.currency !== tok.key) refuse('E-POOL-MINT', `${where}[${i}].to`, `${what} is paid in ${tok.symbol}, but the pool “${p.key}” holds ${m.tokens.get(p.currency)?.symbol ?? p.currency}.`, `Send it to a pool of ${tok.symbol}.`);
|
|
820
|
+
}
|
|
821
|
+
// A lottery's pot of this spec, in the token the share is paid in.
|
|
822
|
+
if (s.kind === 'lottery') {
|
|
823
|
+
const l = m.lottery.get(s.key);
|
|
824
|
+
const potKey = l?.currency ?? m.currencyKey;
|
|
825
|
+
if (!l) refuse('E-REF', `${where}[${i}].to`, `There is no lottery “${s.key}”.`, 'Name one of "lotteries" by its key.');
|
|
826
|
+
else if (tok && potKey !== tok.key) refuse('E-POOL-MINT', `${where}[${i}].to`, `${what} is paid in ${tok.symbol}, but the pot of ${label(l)} is in ${m.tokens.get(potKey)?.symbol ?? potKey}.`, `Send it to a lottery whose pot is in ${tok.symbol}.`);
|
|
827
|
+
}
|
|
828
|
+
// A farm's rewards vault of this spec, in the token the share is paid in.
|
|
829
|
+
if (s.kind === 'farm') {
|
|
830
|
+
const f = m.farm.get(s.key);
|
|
831
|
+
if (!f) refuse('E-REF', `${where}[${i}].to`, `There is no farm “${s.key}”.`, 'Name one of "farms" by its key.');
|
|
832
|
+
else if (tok && f.rewardsToken !== tok.key) refuse('E-POOL-MINT', `${where}[${i}].to`, `${what} is paid in ${tok.symbol}, but ${label(f)}’s rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken}.`, `Send it to a farm whose rewards are ${tok.symbol}.`);
|
|
833
|
+
}
|
|
834
|
+
if (['account', 'holder', 'wallet'].includes(s.kind) && !isAddress(s.key)) refuse('E-REF', `${where}[${i}].to`, `${what} sends a share to “${s.to}”, which is not an address.`, `Write "${s.kind}:<address>".`);
|
|
835
|
+
// A token account named as it is can't be opened for anyone: while none is there, its share goes to the treasury.
|
|
836
|
+
if (s.kind === 'account' && isAddress(s.key) && gameVault(s.key, tok)) refuse('E-SPLIT', `${where}[${i}].to`, `${what} sends a share to the game’s own vault for ${tok.symbol}, where open orders, prizes set aside and bids wait: the program refuses it (it would mix into what those records hold).`, 'Send it to a pool ("pool:<key>") or the treasury instead.');
|
|
837
|
+
else if (s.kind === 'account' && isAddress(s.key)) warn('W-PAYEE', `${where}[${i}].to`, `${what} pays a share to the token account ${s.key.slice(0, 4)}…: while no ${tok?.symbol ?? 'such'} account is there, that share goes to the treasury instead.`, `For a person's wallet, write "wallet:${s.key}": the program pays their own ${tok?.symbol ?? ''} account, and opens it when it is not there.`);
|
|
838
|
+
if (s.kind === 'chosen' && !chosen)
|
|
839
|
+
refuse('E-CHOSEN', `${where}[${i}].to`, `${what} can’t let anyone choose where a share goes: only a buyer chooses, when they pay (a box, a shop item, a lottery’s tickets, a payment).`, 'Name the destination, or sell it as a box with one outcome that gives nothing (or the item): a box can carry the "chosen" share, a gate and opens/closes.');
|
|
840
|
+
// `pay` takes its shares from the payer as they are and checks no collectible against an item: a holder's share
|
|
841
|
+
// the payer picks could name any collectible at all.
|
|
842
|
+
else if (s.kind === 'chosen' && s.key && chosen === 'wallet')
|
|
843
|
+
refuse('E-CHOSEN', `${where}[${i}].to`, `${what} lets the payer pick a “${s.key}” whose holder is paid, but a payment can’t check that the collectible they name is one: only a box’s (or shop item’s) and a lottery’s buyer picks a collectible of an item.`, 'Write "chosen" for a wallet the payer names (a referrer), or sell it as a box with one outcome that gives nothing: a box’s split can carry "chosen:<item>".');
|
|
844
|
+
if (s.kind === 'chosen' && s.key && !m.item.has(s.key)) refuse('E-REF', `${where}[${i}].to`, `${what} lets the buyer pick a “${s.key}”, which is not an item.`, 'Name an item of the game ("chosen:<item key>"), or write "chosen" for a wallet.');
|
|
845
|
+
if (tok?.wsol && s.kind === 'burn')
|
|
846
|
+
refuse('E-WSOL-BURN', `${where}[${i}].to`, `${what} is paid in SOL, and wrapped SOL cannot be burned.`, 'Send that share to a pool or a wallet instead.');
|
|
847
|
+
}
|
|
848
|
+
return total;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
const feeNote = (price, tok, where, what) => {
|
|
852
|
+
if (!tok?.wsol || price === null) return;
|
|
853
|
+
if (price === 0n) return;
|
|
854
|
+
const ratio = Number(FEES.player) / Number(price);
|
|
855
|
+
if (ratio >= 0.01)
|
|
856
|
+
warn('W-FEE', where, `Every ${what} also pays the platform’s player fee of ${fromUnits(FEES.player, 9)} SOL: ${(ratio * 100).toFixed(1)}% on top of its ${fromUnits(price, 9)} SOL price.`, 'Fine for a game; a higher price makes the fee a smaller part of it.');
|
|
857
|
+
};
|
|
858
|
+
/** A part's "perWalletEach": its window (a day when it names none) in 4 bytes. */
|
|
859
|
+
const perWalletWindow = (part, where) => {
|
|
860
|
+
if (part.perWalletEach) fourByteSeconds(part.perWalletEach.every ?? '1d', `${where}.perWalletEach.every`);
|
|
861
|
+
};
|
|
862
|
+
const prices = (part, tok, where) => {
|
|
863
|
+
if (part.rise) {
|
|
864
|
+
units(part.rise.step, tok, `${where}.rise.step`, `${label(part)}’s rise`);
|
|
865
|
+
percent(part.rise.growthPercent, `${where}.rise.growthPercent`);
|
|
866
|
+
}
|
|
867
|
+
if (part.fall) {
|
|
868
|
+
const step = units(part.fall.step, tok, `${where}.fall.step`, `${label(part)}’s fall`);
|
|
869
|
+
if (fourByteSeconds(part.fall.every, `${where}.fall.every`) === 0) refuse('E-PRICE', `${where}.fall.every`, `${label(part)}’s price falls every 0 seconds: the program refuses it.`, 'Give the fall a time above 0.');
|
|
870
|
+
else if (step === 0n) warn('W-PRICE', `${where}.fall.step`, `${label(part)}’s price falls by 0: it never falls.`, 'Give the fall a step above 0, or leave "fall" out.');
|
|
871
|
+
const floor = units(part.fall.floor, tok, `${where}.fall.floor`, `${label(part)}’s floor`);
|
|
872
|
+
if (floor === 0n) warn('W-PRICE', `${where}.fall.floor`, `${label(part)}’s price can fall to 0: once it gets there, it is free until a rise lifts it.`, 'Give the fall a floor above 0 if it should never be free.');
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// ---- the collections and items
|
|
877
|
+
if (m.collection && toBps(m.collection.royaltyPercent) === null) refuse('E-PERCENT', 'economy.collection.royaltyPercent', 'The royalty is finer than hundredths of a percent.', 'Round it to two decimals.');
|
|
878
|
+
// A project takes the one collection it makes and the owner's others: as many as `create_project`'s transaction carries
|
|
879
|
+
// (about 25; no cap of the program's own), which the setup's size check below refuses past (E-TX-SIZE).
|
|
880
|
+
tooMany(m.collections.length + (m.collection ? 1 : 0), LIMITS.collections, 'E-REF', 'economy.collections', `The project takes ${m.collections.length + (m.collection ? 1 : 0)} collections`, 'BadCollection', 'Take fewer: a gate or a boost can name a collection the project doesn’t take.');
|
|
881
|
+
m.collections.forEach((c, i) => isAddress(c) || refuse('E-REF', `economy.collections[${i}]`, `“${c}” is not an address.`, 'Write the Core collection’s address.'));
|
|
882
|
+
if (m.collections.length) warn('W-COLLECTIONS', 'economy.collections', `The project takes ${m.collections.length} collection${m.collections.length === 1 ? '' : 's'} you already have: going live passes ${m.collections.length === 1 ? 'it' : 'them'} to the program, which refuses one whose update authority is not your wallet (BadCollection). An item minted into one needs the project as an update delegate of it, which you add to the collection yourself.`);
|
|
883
|
+
const given = new Set();
|
|
884
|
+
for (const b of m.allBoxes) for (const o of (b.outcomes ?? []).flatMap(outcomeParts)) if (o.gives === 'item') given.add(o.item);
|
|
885
|
+
for (const p of prizes(m)) if (p.prize.gives === 'item') given.add(p.prize.item);
|
|
886
|
+
for (const a of m.auctions) if (a.lot?.item) given.add(a.lot.item);
|
|
887
|
+
for (const d of m.decisions) if (d.does === 'make') given.add(d.item);
|
|
888
|
+
// A fresh item is given only by a box's roll: a route the player pays or asks for refuses it (PrizeNotCovered); one that
|
|
889
|
+
// only finishes (a lottery place, a contest, a mission's end) gives nothing in its place; an auction refuses the lot.
|
|
890
|
+
const fix = 'Give a roll of a box that gives it (`{ "gives": "roll", "box": key }`) instead.';
|
|
891
|
+
for (const p of prizes(m)) {
|
|
892
|
+
const it = p.prize.gives === 'item' ? m.item.get(p.prize.item) : null;
|
|
893
|
+
if (!(it?.drawn ?? []).length) continue;
|
|
894
|
+
if (p.strict) refuse('E-ITEM', p.where, `${label(it)} draws its traits per unit, which only a box’s roll does: a ${p.route} gives alike units (PrizeNotCovered).`, fix);
|
|
895
|
+
else warn('W-ITEM', p.where, `${label(it)} draws its traits per unit, which only a box’s roll does: as a ${p.route}’s prize it is never given (its winner gets nothing for it).`, fix);
|
|
896
|
+
}
|
|
897
|
+
for (const a of m.auctions) {
|
|
898
|
+
const it = a.lot?.item ? m.item.get(a.lot.item) : null;
|
|
899
|
+
if ((it?.drawn ?? []).length) refuse('E-AUCTION', `economy.auctions.${a.key}.lot.item`, `${label(it)} draws its traits per unit, which only a box’s roll does: an auction can’t sell a new one (LotRefused).`, 'Auction a roll of a box that gives it (`"lot": { "roll": key }`) instead.');
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// A table: rows that weigh something, a label of 32 bytes at most in a table of labels, none in one of numbers.
|
|
903
|
+
for (const t of m.tables) {
|
|
904
|
+
const where = `economy.tables.${t.key}`;
|
|
905
|
+
if (!(t.rows ?? []).length) refuse('E-TABLE', `${where}.rows`, `The table “${t.key}” has no rows.`, 'Give it at least one row.');
|
|
906
|
+
(t.rows ?? []).forEach((r, i) => {
|
|
907
|
+
const bytes = new TextEncoder().encode(String(r.label ?? '')).length;
|
|
908
|
+
if (t.kind === 'numbers' && r.label !== undefined) refuse('E-TABLE', `${where}.rows[${i}].label`, `The table “${t.key}” is of numbers: a row gives its own number, and takes no label (BadTable).`, 'Remove the label, or make the table "labels".');
|
|
909
|
+
else if (t.kind !== 'numbers' && (bytes === 0 || bytes > 32)) refuse('E-TABLE', `${where}.rows[${i}].label`, `Row ${i} of the table “${t.key}” has a label of ${bytes} bytes: a label is 1 to 32 bytes (BadTable).`, 'Shorten it.');
|
|
910
|
+
});
|
|
911
|
+
if (!m.items.some((it) => (it.drawn ?? []).some((d) => d.table === t.key))) warn('W-TABLE', where, `No item draws a trait from the table “${t.key}”.`, 'Name it in an item’s "drawn", or leave it out.');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
for (const it of m.items) {
|
|
915
|
+
const where = `economy.items.${it.key}`;
|
|
916
|
+
// A fresh collectible: each trait its own attribute, drawn from a table the spec has or a range from its least to its
|
|
917
|
+
// most; given only by a box's roll (a shop sells alike units: PrizeNotCovered).
|
|
918
|
+
const drawn = it.drawn ?? [];
|
|
919
|
+
if (drawn.length && !isCollectible(it)) refuse('E-ITEM', `${where}.drawn`, `${label(it)} draws traits, but it is a normal item: only a collectible carries them.`, 'Make it a collectible, or leave "drawn" out.');
|
|
920
|
+
// Each trait is written as its unit is minted, which one transaction must hold (catalog.rs).
|
|
921
|
+
tooMany(drawn.length, LIMITS.itemTraits, 'E-ITEM', `${where}.drawn`, `${label(it)} draws ${drawn.length} traits`, 'BadItem', 'Draw fewer: put what goes together in one table’s labels.');
|
|
922
|
+
drawn.forEach((d, i) => {
|
|
923
|
+
const at = `${where}.drawn[${i}]`;
|
|
924
|
+
if (utf8Bytes(d.attribute ?? '') > LIMITS.traitKeyBytes) refuse('E-ITEM', `${at}.attribute`, `${label(it)}’s trait “${String(d.attribute).slice(0, 24)}…” has a name of ${utf8Bytes(d.attribute)} bytes; the program takes at most ${LIMITS.traitKeyBytes} (BadItem).`, 'Use a shorter name.');
|
|
925
|
+
if (RESERVED_ATTRIBUTES.includes(d.attribute)) refuse('E-ITEM', `${at}.attribute`, `${label(it)} draws “${d.attribute}”, an attribute the program writes itself (BadItem).`, 'Name another attribute.');
|
|
926
|
+
if (drawn.slice(0, i).some((e) => e.attribute === d.attribute)) refuse('E-ITEM', `${at}.attribute`, `${label(it)} draws “${d.attribute}” twice (BadItem).`, 'Draw each attribute once.');
|
|
927
|
+
if (d.table !== undefined && (d.min !== undefined || d.max !== undefined)) refuse('E-ITEM', at, `${label(it)}’s trait “${d.attribute}” names a table and a range.`, 'Keep one.');
|
|
928
|
+
else if (d.table !== undefined && !m.table.get(d.table)) refuse('E-REF', `${at}.table`, `${label(it)}’s trait “${d.attribute}” draws from “${d.table}”, which is not one of the spec’s tables.`, 'Add the table to "tables", or name one it has.');
|
|
929
|
+
else if (d.table === undefined && !(d.min !== undefined && d.max !== undefined && d.min <= d.max)) refuse('E-ITEM', at, `${label(it)}’s trait “${d.attribute}” needs a table, or a range from its "min" to its "max", the least first.`, 'Name a table, or give a "min" and a "max".');
|
|
930
|
+
});
|
|
931
|
+
if (drawn.length && (Number(it.price ?? 0) > 0 || it.free)) refuse('E-ITEM', `${where}.price`, `${label(it)} draws its traits per unit, by a roll: the shop sells only alike units (PrizeNotCovered).`, 'Sell it from a box (one outcome is enough: the box still rolls), and leave "price" out.');
|
|
932
|
+
// A collectible is minted into a collection of the project's (catalog.rs BadCollection).
|
|
933
|
+
if (isCollectible(it) && it.collection && !m.collections.includes(it.collection))
|
|
934
|
+
refuse('E-COLLECTION', `${where}.collection`, `${label(it)} is minted into ${it.collection}, which is not one of the project’s "collections" (BadCollection).`, 'Add it to "collections", or leave "collection" out to mint into the one the setup makes.');
|
|
935
|
+
if (isCollectible(it) && !it.collection && !m.collection)
|
|
936
|
+
refuse('E-COLLECTION', `${where}.collection`, `${label(it)} is a collectible, but the project makes no collection ("collection": false) and the item names none.`, 'Name one of "collections" as its "collection", make it a normal item, or let the setup make a collection.');
|
|
937
|
+
if ((it.uri ?? '').includes('{serial}') && !isCollectible(it)) warn('W-ITEM', `${where}.uri`, `${label(it)} is a normal item: "{serial}" in its link is written only on collectibles.`);
|
|
938
|
+
const price = units(it.price, game, `${where}.price`, `${label(it)}’s price`);
|
|
939
|
+
prices(it, game, where);
|
|
940
|
+
perWalletWindow(it, where);
|
|
941
|
+
// A collectible's life, each time in 4 bytes (a normal item's is dropped, warned of below).
|
|
942
|
+
if (isCollectible(it) && it.life) for (const k of ['readyAfter', 'lasts', 'cooldown']) fourByteSeconds(it.life[k], `${where}.life.${k}`);
|
|
943
|
+
if (it.kind === 'normal' && (it.life || it.soulbound || it.burnable))
|
|
944
|
+
warn('W-ITEM', where, `${label(it)} is a normal item, kept by the project’s backend: its life, soulbound and burnable settings only apply to collectibles, so the program drops them.`, 'Make it a collectible, or remove those settings.');
|
|
945
|
+
split(it.split, game, `${where}.split`, { what: `${label(it)}’s sale`, chosen: true });
|
|
946
|
+
feeNote(price, game, `${where}.price`, `purchase of ${label(it)}`);
|
|
947
|
+
rule(it.gate, `${where}.gate`, `${label(it)}’s shop listing`);
|
|
948
|
+
if (price === 0n && !it.free && !given.has(it.key))
|
|
949
|
+
warn('W-ITEM', where, `Nobody can get ${label(it)}: it is not in the shop (price 0), and no box, recipe, grant or decision gives it.`, 'Give it a price (or "free": true), or put it in a box, a recipe or a grant.');
|
|
950
|
+
if (it.free) warn('W-FREE', where, `${label(it)} is free in the shop: anyone, a bot included, can take ${it.supply ? `all ${it.supply}` : 'as many as they like'} for only the fees.`, 'Give it a gate, a supply, or hand it out through a grant with limits per wallet and per person.');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ---- pools: what fills them and what takes from them
|
|
954
|
+
const takers = poolTakers(m);
|
|
955
|
+
const fillers = poolFillers(m);
|
|
956
|
+
for (const p of m.pools) {
|
|
957
|
+
const where = `economy.pools.${p.key}`;
|
|
958
|
+
const tok = m.tokens.get(p.currency);
|
|
959
|
+
units(p.seed, tok, `${where}.seed`, `The seed of “${p.key}”`);
|
|
960
|
+
const takes = takers.filter((t) => t.pool === p.key);
|
|
961
|
+
const fills = fillers.filter((f) => f.pool === p.key);
|
|
962
|
+
if (takes.length && !fills.length)
|
|
963
|
+
warn('W-POOL-DRY', where, `The pool “${p.key}” is paid out of (${takes.map((t) => t.by).join(', ')}) but nothing in this spec fills it: until someone sends it tokens, those payouts give nothing (a claim of a fixed amount is refused).`, `Seed it ("seed"), send it a share of a sale (a split to "pool:${p.key}"), or fill it with a schedule.`);
|
|
964
|
+
if (!takes.length && !fills.length) warn('W-POOL', where, `The pool “${p.key}” is never used.`, 'Remove it, or use it.');
|
|
965
|
+
if (fills.length && !takes.length)
|
|
966
|
+
warn('W-POOL', where, `Money goes into “${p.key}” (${fills.map((f) => f.by).join(', ')}) and nothing in the economy takes it out: only the authority can, with a payout.`, 'Add what pays from it (a box prize, a lottery, a farm, a recipe, a grant), or a decision that pays out of it.');
|
|
967
|
+
const lottery = takes.find((t) => t.by.startsWith('lottery:'));
|
|
968
|
+
if (lottery) {
|
|
969
|
+
const others = takes.filter((t) => t !== lottery);
|
|
970
|
+
if (others.length) warn('W-POOL-SHARED', where, `The pot “${p.key}” of ${lottery.by} is also taken from by ${others.map((t) => t.by).join(', ')}: until its winners are paid it is a pool like any other, and the winners share what is left.`, 'Give the lottery a pot of its own.');
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ---- stashes (stash.rs): NFTs the game holds to give away, one at random a roll
|
|
975
|
+
// Every roll of a box, a box's own outcome's and a roll's roll included: who can empty a stash, who wins a box,
|
|
976
|
+
// whose roll fails at its ask.
|
|
977
|
+
const rolls = rollsOf(m);
|
|
978
|
+
/** Who makes a roll, in words: "grant:gift", or "grant:gift through box:mid" for a box that a box it rolls rolls. */
|
|
979
|
+
const roller = (r) => (r.through?.length ? `${r.by} through ${r.through.map((k) => `box:${k}`).join(', ')}` : r.by);
|
|
980
|
+
const stashUsers = new Map(m.stashes.map((s) => [s.key, m.allBoxes.filter((b) => (b.outcomes ?? []).flatMap(outcomeParts).some((o) => o.gives === 'stash' && o.stash === s.key))]));
|
|
981
|
+
// What fills a stash besides its owner: an NFT a box takes as its price, a recipe's trade-in, a lottery's ticket.
|
|
982
|
+
const intoStash = new Map();
|
|
983
|
+
const into = (then, by) => then?.stash && intoStash.set(then.stash, [...(intoStash.get(then.stash) ?? []), by]);
|
|
984
|
+
for (const b of m.allBoxes) for (const o of b.outcomes ?? []) into(o.then ?? b.then, `box:${b.key}`);
|
|
985
|
+
for (const r of m.recipes) for (const l of r.in ?? []) if (l.takes === 'trade') into(l.then, `recipe:${r.key}`);
|
|
986
|
+
for (const l of m.lotteries) into(l.ticketNft?.then, `lottery:${l.key}`);
|
|
987
|
+
// The prizes that name one NFT of a stash (prize.rs Prize::Nft), given at once.
|
|
988
|
+
const directs = prizes(m).filter((p) => p.prize.gives === 'nft');
|
|
989
|
+
const listed = new Map();
|
|
990
|
+
for (const s of m.stashes) {
|
|
991
|
+
const where = `economy.stashes.${s.key}`;
|
|
992
|
+
if (utf8Bytes(s.name) > 40) refuse('E-STASH', `${where}.name`, `${label(s)}’s name is longer than 40 bytes.`, 'Shorten it.');
|
|
993
|
+
if (stashNfts(s).some((n) => n.kind === 'token-metadata'))
|
|
994
|
+
warn('W-STASH', `${where}.nfts`, `${label(s)} lists Token Metadata NFTs: each must be an NFT (one token, no decimals, a mint that can never mint another); a semi-fungible item is refused when it is put in (NotAnNft).`);
|
|
995
|
+
for (const [i, n] of stashNfts(s).entries()) {
|
|
996
|
+
const at = `${where}.nfts[${i}]`;
|
|
997
|
+
if (listed.has(n.asset)) refuse('E-STASH', at, `${label(s)} lists ${n.asset}, which ${listed.get(n.asset)} lists too: an NFT can be handed to the game once.`, 'List each NFT in one stash, once.');
|
|
998
|
+
listed.set(n.asset, label(s));
|
|
999
|
+
if (n.kind === 'compressed')
|
|
1000
|
+
refuse('E-STASH', at, `${label(s)} lists a compressed NFT: putting one in needs a proof of its leaf fetched when it is signed, which going live does not do.`, 'Leave it out here; once the economy is live, put it in with `loot.proveLeafIx` then `loot.stashIx` (both in one transaction, signed by the owner or the authority who holds it). A compressed prize NFT can’t move in the same instruction as another move on its tree (a mission whose staked NFTs share that tree, a lottery entry winning two places of one tree): the second proof is stale and the unstake or the payment fails until it is sent again. Keep prize NFTs on a tree of their own, or give them through a roll.');
|
|
1001
|
+
if (n.kind === 'core' && !n.collection && !m.live.project)
|
|
1002
|
+
refuse('E-STASH', at, `${label(s)} lists an NFT of the game’s own collection, which is only made when the economy goes live: there is none to put in yet.`, 'Give the collection it is in ("collection"), or put it in once the economy is live and your wallet holds it.');
|
|
1003
|
+
}
|
|
1004
|
+
const users = stashUsers.get(s.key);
|
|
1005
|
+
const named = directs.filter((p) => p.prize.stash === s.key);
|
|
1006
|
+
// A known limit of the program: a named NFT is found by reading the stash's list from its start, about 52
|
|
1007
|
+
// compute units for each NFT ahead of it, and a box's roll looks for every NFT it names, then takes the one won. A
|
|
1008
|
+
// stash that players fill (a box's fate, a recipe's trade-in, a lottery's NFT ticket) can grow past what fits.
|
|
1009
|
+
const namers = m.allBoxes.map((b) => [b, (b.outcomes ?? []).flatMap(outcomeParts).filter((o) => o.gives === 'nft' && o.stash === s.key).length]).filter(([, n]) => n);
|
|
1010
|
+
if ((namers.length || named.length) && intoStash.has(s.key)) {
|
|
1011
|
+
const most = Math.max(0, ...namers.map(([, n]) => n));
|
|
1012
|
+
const fits = Math.floor(1_200_000 / (52 * (most + 1)) / 100) * 100;
|
|
1013
|
+
warn('W-STASH-NAMED', where, `${[...namers.map(([b]) => label(b)), ...named.map((p) => p.by)].join(', ')} name${namers.length + named.length === 1 ? 's' : ''} NFTs of ${label(s)}, which ${[...new Set(intoStash.get(s.key))].join(', ')} put${intoStash.get(s.key).length === 1 ? 's' : ''} NFTs into, so it can grow without end. A named NFT is found by reading the stash’s list, about 52 compute units for each NFT ahead of it${most ? `: a roll of ${label(namers.find(([, n]) => n === most)[0])}, which names ${most}, stops fitting a transaction at about ${fits.toLocaleString('en-US')} NFTs in the stash (its order then waits for its refund)` : ''}${named.length ? `${most ? ', and' : ':'} a prize naming one stops at about 25,000` : ''}. Keep a stash that prizes name under about 1,000 NFTs.`, 'Send what players put in to a stash of its own, and name prizes from a stash only the owner fills; a random pick of a stash costs the same at any size.');
|
|
1014
|
+
}
|
|
1015
|
+
if (!(s.nfts ?? []).length && !intoStash.has(s.key)) {
|
|
1016
|
+
// A box that stops selling when an outcome runs out (the default) isn't sold at all while it is empty.
|
|
1017
|
+
const stopped = users.filter((b) => b.active !== false && b.whenOut !== 'drawLeft' && !boxBuys(b, m));
|
|
1018
|
+
warn('W-STASH', `${where}.nfts`, `${label(s)} starts empty: until the owner or the authority puts NFTs in (\`loot.stashIx\`), ${stopped.length ? `${stopped.map(label).join(', ')} ${stopped.length === 1 ? 'isn’t' : 'aren’t'} sold at all (it stops selling while an outcome is out, "whenOut": "stopSales"), and ` : ''}a draw that lands on it is not made and gives back that draw’s worth, no other outcome taking its place, and a box of nothing else sells nothing.`);
|
|
1019
|
+
}
|
|
1020
|
+
if (!users.length && !named.length) warn('W-STASH', where, `No box and no prize gives an NFT of ${label(s)}: what it holds stays with the game.`, 'Add an outcome { "gives": "stash", "stash": key } to a box, or a prize { "gives": "nft", "stash": key, "nft": address }.');
|
|
1021
|
+
// Who takes from it: buyers and every route that rolls those boxes (one NFT a roll), and the prizes naming one.
|
|
1022
|
+
const takers = [...new Set([...users.flatMap((b) => [...(b.active !== false ? [b.shopOf ? `item:${b.shopOf}` : `box:${b.key}`] : []), ...rolls.filter((r) => r.box === b.key).map((r) => r.by)]), ...named.map((p) => p.by)])];
|
|
1023
|
+
if (takers.length > 1)
|
|
1024
|
+
warn('W-STASH-SHARED', where, `${label(s)} is given by ${takers.join(', ')}: ${users.length ? 'each roll takes an NFT out at random' : ''}${users.length && named.length ? ', and ' : ''}${named.length ? 'a prize that names an NFT finds it gone once a roll took it' : ''}, so one route can empty it before another pays, and a winner then gets the box’s other outcomes, or nothing. A lottery’s, a contest’s or an auction’s winner gets theirs only once the result is in.`, 'Give each route a stash of its own (a named NFT in a stash no box rolls), or keep it stocked.');
|
|
1025
|
+
}
|
|
1026
|
+
// A prize that names one NFT gives it once: every later winner of the route gets nothing (a grant's claim or a craft
|
|
1027
|
+
// is refused).
|
|
1028
|
+
for (const p of directs) {
|
|
1029
|
+
if (p.once) continue;
|
|
1030
|
+
warn('W-PRIZE-ONCE', p.where, `${p.by} gives ${prizeWords(p.prize, m)}, one NFT: the first winner takes it, and ${p.strict ? 'every claim or craft after is refused (PrizeNotCovered)' : 'every winner after gets nothing for it'}.`, p.route === 'grant' ? 'Limit the grant to one claim ("limits.total": 1), or give a roll of a box of a stash of many.' : 'Give a roll of a box of a stash of many, or an item or tokens each winner can get.');
|
|
1031
|
+
}
|
|
1032
|
+
// The game's own collection moves as the game says; any other's, as whoever controls it says.
|
|
1033
|
+
const others = m.stashes.filter((s) => stashNfts(s).some((n) => !(n.kind === 'core' && !n.collection)));
|
|
1034
|
+
if (others.length) {
|
|
1035
|
+
const ruled = others.flatMap((s) => stashNfts(s)).filter((n) => n.kind === 'token-metadata' && n.programmable && n.ruleSet).length;
|
|
1036
|
+
warn('W-STASH-TRANSFER', 'economy.stashes', `${others.map(label).join(', ')} hold${others.length === 1 ? 's' : ''} NFTs of collections the game has no authority over. An NFT goes to its winner through its own program, and whoever controls its collection can make that refuse (a Core freeze or a permanent delegate, a pNFT rule set that does not let programs hold or send it${ruled ? `; ${ruled} listed have a rule set` : ''}): a won order then can’t be settled until they allow it, and a prize that names such an NFT holds up what gives it (a lottery round’s last payment, a contest’s claim, a mission’s unstake) until the owner or the authority drops it from the stash (\`loot.stashIx\` out to the game’s own address).`, 'Stash NFTs of collections that let them move freely (the game’s own, or a partner’s you trust).');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// ---- boxes (catalog.rs, order.rs)
|
|
1040
|
+
let chance = false;
|
|
1041
|
+
let randomItems = false;
|
|
1042
|
+
/** Per pool, the largest fixed prize a box pays from it, and every box that pays one: warned of once a pool, below. */
|
|
1043
|
+
const seedNeeds = new Map();
|
|
1044
|
+
for (const b of m.boxes) {
|
|
1045
|
+
const where = `economy.boxes.${b.key}`;
|
|
1046
|
+
// A pack: each slot draws at least once, among outcomes the box has, each named once. Several draws need a box that
|
|
1047
|
+
// rolls, or one outcome of items, with no outcome that burns the payment and no NFT to roll on (BadEntries).
|
|
1048
|
+
const slots = b.slots ?? [];
|
|
1049
|
+
const outs = b.outcomes ?? [];
|
|
1050
|
+
tooMany(outs.length, LIMITS.boxEntries, 'E-ODDS', `${where}.outcomes`, `${label(b)} has ${outs.length} outcomes`, 'BadEntries', 'Use fewer outcomes, or roll on a table of rows ("drawn").');
|
|
1051
|
+
tooMany(slots.length, LIMITS.boxSlots, 'E-ODDS', `${where}.slots`, `${label(b)} has ${slots.length} slots`, 'BadEntries', 'Put alike draws into one slot with "count".');
|
|
1052
|
+
tooMany((b.odds ?? []).length, LIMITS.boxOdds, 'E-ODDS', `${where}.odds`, `${label(b)} has ${(b.odds ?? []).length} odds that read an NFT`, 'BadEntries', 'Keep the ones that matter.');
|
|
1053
|
+
const fated = outs.filter((o) => (o.shifts ?? []).length || (o.then ?? b.then ?? 'back') !== 'back').length;
|
|
1054
|
+
tooMany(fated, LIMITS.boxFates, 'E-ROLL', `${where}.outcomes`, `${fated} outcomes of ${label(b)} act on the NFT it rolls on`, 'BadEntries', 'Have fewer outcomes act on it.');
|
|
1055
|
+
for (const [i, o] of outs.entries()) {
|
|
1056
|
+
const given = outcomeParts(o);
|
|
1057
|
+
tooMany(given.length, LIMITS.entryPrizes, 'E-ODDS', `${where}.outcomes[${i}].prizes`, `An outcome of ${label(b)} gives ${given.length} prizes`, 'BadEntries', 'Give fewer, or make it two outcomes.');
|
|
1058
|
+
// An outcome gives all it names or isn't given (order.rs): what it names is refused at the write when it can clash.
|
|
1059
|
+
for (const [j, x] of given.entries()) {
|
|
1060
|
+
const clash = given.slice(0, j).map((y) => samePrizes(y, x)).find(Boolean);
|
|
1061
|
+
if (clash) refuse('E-ODDS', `${where}.outcomes[${i}].prizes[${j}]`, `An outcome of ${label(b)} ${clash}: it gives all it names or isn't given, so the program refuses the box (BadEntries).`, 'Name each pool once, with the whole amount, and a stash\'s random NFT beside nothing else of that stash.');
|
|
1062
|
+
}
|
|
1063
|
+
// Two of an item take two units: an entry of more than its supply can never be given.
|
|
1064
|
+
for (const key of new Set(given.filter((x) => x.gives === 'item').map((x) => x.item))) {
|
|
1065
|
+
const count = given.filter((x) => x.gives === 'item' && x.item === key).length;
|
|
1066
|
+
const supply = Number(m.item.get(key)?.supply ?? 0);
|
|
1067
|
+
if (supply > 0 && count > supply) warn('W-OUT-OF-STOCK', `${where}.outcomes[${i}]`, `An outcome of ${label(b)} gives ${count} of ${label(m.item.get(key))}, which has a supply of ${supply}: it needs them all left, so it can never be given${b.whenOut !== 'drawLeft' && b.active !== false && boxRolls(b, m) ? ', and a box that stops selling while an outcome is out ("whenOut": "stopSales") is never sold' : ', and a draw that lands on it gives back that draw’s worth'}.`, 'Give fewer, or raise its supply.');
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// A fresh outcome's tables come with the answer while they fit what the box's own accounts leave, in outcome order
|
|
1071
|
+
// (`tableBudget`). One past that is out of stock on every roll, so its odds would be false.
|
|
1072
|
+
{
|
|
1073
|
+
const box = (() => {
|
|
1074
|
+
try {
|
|
1075
|
+
return boxArgs(b, m);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
})();
|
|
1080
|
+
if (box && boxRolls(b, m)) {
|
|
1081
|
+
let left = Math.max(0, tableBudget(box.entries));
|
|
1082
|
+
const cut = [];
|
|
1083
|
+
outs.forEach((out, i) => {
|
|
1084
|
+
for (const part of outcomeParts(out)) {
|
|
1085
|
+
if (part.gives !== 'item') continue;
|
|
1086
|
+
const tables = (m.item.get(part.item)?.drawn ?? []).filter((d) => d.table).length;
|
|
1087
|
+
if (tables > left) cut.push(i);
|
|
1088
|
+
else left -= tables;
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
if (cut.length) refuse('E-ODDS', `${where}.outcomes`, `${label(b)} draws from more tables than one answer of the oracle’s can carry, so outcome${cut.length === 1 ? '' : 's'} ${[...new Set(cut)].join(', ')} could never be won.`, 'Fewer outcomes with drawn traits, fewer table traits on each, or a range (`min`, `max`) in place of a table.');
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
slots.forEach((s, i) => (s.from ?? []).forEach((o, j) => {
|
|
1095
|
+
if (o >= outs.length) refuse('E-ODDS', `${where}.slots[${i}].from[${j}]`, `Slot ${i} of ${label(b)} names outcome ${o}, but the box has ${outs.length}.`, 'Name outcomes by their place in "outcomes", from 0.');
|
|
1096
|
+
else if ((s.from ?? []).slice(0, j).includes(o)) refuse('E-ODDS', `${where}.slots[${i}].from[${j}]`, `Slot ${i} of ${label(b)} names outcome ${o} twice.`, 'Name each once.');
|
|
1097
|
+
}));
|
|
1098
|
+
// A compressed item an outcome gives is minted at the settle, as any prize's (`compressedCheck`).
|
|
1099
|
+
outs.forEach((o, i) => outcomeParts(o).forEach((part) => part.gives === 'item' && part.compressed && m.item.get(part.item) && compressedCheck(m.item.get(part.item), `${where}.outcomes[${i}]`, label(b), 'E-ODDS')));
|
|
1100
|
+
const cards = slots.reduce((n, s) => n + s.count, 0);
|
|
1101
|
+
// Slots that name outcomes draw different things, so each says what it is worth (BadEntries): what a draw that
|
|
1102
|
+
// can't be made gives back.
|
|
1103
|
+
const shared = slots.reduce((n, s) => n + Math.round((s.share ?? 0) * 100), 0);
|
|
1104
|
+
const named = outs.length > 1 && slots.some((s) => (s.from ?? []).length);
|
|
1105
|
+
const zero = named && slots.some((s) => !s.share);
|
|
1106
|
+
if (zero || shared !== (named ? 10_000 : 0))
|
|
1107
|
+
refuse('E-ODDS', `${where}.slots`, named ? `The slots of ${label(b)} name outcomes, so each says what it is worth as a share of an open's price, more than 0 each and 100 together; these come to ${shared / 100}.` : `The slots of ${label(b)} all draw among every outcome, so every draw is worth the same: they take no share.`, named ? 'Give each slot a "share": a draw that can\'t be made gives its slot\'s worth back, so make a rare slot worth what the rare is.' : 'Leave "share" out.');
|
|
1108
|
+
// Every outcome is one some slot draws (BadEntries): one no slot draws would count as stock no roll can give.
|
|
1109
|
+
if (slots.length && !slots.some((s) => !(s.from ?? []).length)) {
|
|
1110
|
+
const undrawn = outs.map((_, i) => i).filter((i) => !slots.some((s) => (s.from ?? []).includes(i)));
|
|
1111
|
+
if (undrawn.length)
|
|
1112
|
+
refuse('E-ODDS', `${where}.slots`, `No slot of ${label(b)} draws its outcome${undrawn.length === 1 ? '' : 's'} ${undrawn.join(', ')}: an outcome no slot draws is never given, so the program refuses the box (BadEntries).`, 'Name each outcome in some slot’s "from", leave "from" out of a slot (it draws among all), or drop the outcome.');
|
|
1113
|
+
}
|
|
1114
|
+
// Every draw of one open fits one answer of the oracle's (PackTooLarge): a pack past it could only draw its first
|
|
1115
|
+
// cards, so the program refuses the box. A box that doesn't roll sells by `buy`, the whole purchase in one
|
|
1116
|
+
// transaction, or through the oracle when one `buy` can't carry it (`buyFits`): then the answer's heap counts, and it
|
|
1117
|
+
// can't give a roll, which only `buy` gives (catalog.rs). The heap counts the box's own bytes too (`boxBytes`).
|
|
1118
|
+
if (cards > 1 && outs.length) {
|
|
1119
|
+
const args = (() => {
|
|
1120
|
+
try {
|
|
1121
|
+
return boxArgs(b, m);
|
|
1122
|
+
} catch {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
})();
|
|
1126
|
+
const entries = args?.entries;
|
|
1127
|
+
if (entries) {
|
|
1128
|
+
const byBuy = boxRolls(b, m) || Boolean(b.rollsOn) || buyFits(args, cards);
|
|
1129
|
+
const extras = { odds: args.odds.length, shares: args.shares.length, fates: args.fates.length, bytes: boxBytes(args), oracle: !byBuy };
|
|
1130
|
+
if (!byBuy && entries.some((e) => e.prizes.some((p) => p.Roll != null)))
|
|
1131
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} gives a roll, which only a sale in one step gives, but ${cards} of what it gives don’t fit one transaction (its accounts, its instructions or its compute), so the program refuses the box (PackTooLarge).`, 'Fewer cards an open, fewer prizes in its outcome, or fewer shares in its split.');
|
|
1132
|
+
else if (!drawsFit(entries, slots.map((s) => ({ count: s.count, only: s.from ?? [] })), 1, extras)) {
|
|
1133
|
+
let most = cards - 1;
|
|
1134
|
+
while (most > 1 && !drawsFit(entries, most, 1, extras)) most -= 1;
|
|
1135
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} draws ${cards} cards an open among ${outs.length} outcome${outs.length === 1 ? '' : 's'}: more than one answer of the oracle’s can draw (about ${most} here), so the program refuses the box (PackTooLarge).`, 'Fewer cards an open (sell two packs instead of one), fewer outcomes, fewer prizes in an outcome, or a smaller box (fewer odds, shares, slots or outcomes that act on the NFT).');
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (cards > 1 && b.rollsOn)
|
|
1140
|
+
refuse('E-ODDS', `${where}.slots`, `${label(b)} draws ${cards} times an open, but a pack has no NFT it rolls on (BadEntries).`, 'Leave "slots" out, or drop "rollsOn".');
|
|
1141
|
+
// An open's order holds every prize it can set aside: at most 10,240 bytes (about 243 one-prize draws).
|
|
1142
|
+
const orderSize = (() => {
|
|
1143
|
+
try {
|
|
1144
|
+
return boxOrderBytes(b, m);
|
|
1145
|
+
} catch {
|
|
1146
|
+
return 0;
|
|
1147
|
+
}
|
|
1148
|
+
})();
|
|
1149
|
+
if (orderSize > MAX_ORDER_BYTES) refuse('E-ODDS', `${where}.slots`, `${label(b)}’s order would be ${orderSize.toLocaleString('en-US')} bytes, more than the ${MAX_ORDER_BYTES.toLocaleString('en-US')} an order can be (BadEntries).`, 'Fewer draws an open, or fewer prizes in its largest outcome.');
|
|
1150
|
+
// A roll moves its prizes aside with no accounts of a transfer hook's, and can't move tokens whose mint allows
|
|
1151
|
+
// confidential transfers or none at all: such a prize is out of stock in every roll (order.rs `mints_in_stock`): a draw
|
|
1152
|
+
// that lands on it is not made and gives back its worth, and a box with nothing else in stock is refused when it is
|
|
1153
|
+
// opened or asked for (SoldOut).
|
|
1154
|
+
const rolledAtAll = !boxBuys(b, m) || rolls.some((r) => r.box === b.key);
|
|
1155
|
+
if (rolledAtAll)
|
|
1156
|
+
for (const [i, o] of outs.entries()) {
|
|
1157
|
+
const tokenOf = (x) => (x.gives === 'pay' ? m.pool.get(x.pool)?.currency : x.gives === 'mint' ? x.currency ?? m.currencyKey : undefined);
|
|
1158
|
+
const bad = outcomeParts(o).map((x) => m.tokens.get(tokenOf(x))).find((t) => t?.powers?.transferHook || t?.powers?.confidentialTransfers);
|
|
1159
|
+
if (!bad) continue;
|
|
1160
|
+
const why = bad.powers.transferHook ? 'whose mint has a transfer hook (a roll carries none of the accounts a hook asks for)' : 'whose mint allows confidential transfers';
|
|
1161
|
+
warn('W-OUT-OF-STOCK', `${where}.outcomes[${i}]`, `${label(b)} pays a prize in ${bad.symbol ?? bad.key}, ${why}: a roll can’t move it, so this outcome is out of stock in every roll of the box: its odds stay as written, a draw that lands on it makes no draw and gives back that draw’s worth, and no other outcome takes its place${outs.length === 1 ? '; with nothing else in it, every opening or roll of the box is refused (SoldOut)' : ''}.`, `Pay its prizes in a token without ${bad.powers.transferHook ? 'a transfer hook' : 'confidential transfers'}${boxRolls(b, m) || b.rollsOn ? '' : ', or sell the box in one step (one outcome, nothing random, no NFT rolled on: the purchase moves its prize with the hook’s accounts)'}.`);
|
|
1162
|
+
}
|
|
1163
|
+
// A roll is given only by a box that sells in one step (`buy`): one oracle answer can't serve a second draw. In a
|
|
1164
|
+
// box that rolls it is refused; in an order (an open, a prize's or a recipe's roll of the box) it is out of stock.
|
|
1165
|
+
const givesRoll = outs.some((o) => outcomeParts(o).some((x) => x.gives === 'roll'));
|
|
1166
|
+
if (givesRoll && boxRolls(b, m))
|
|
1167
|
+
refuse('E-ODDS', `${where}.outcomes`, `${label(b)} rolls (more than one outcome, or an NFT of a stash at random), and gives a roll of a box: a box that rolls can’t give one (BadEntries).`, 'Give the roll from a box of one outcome that sells in one step, or give the box’s prizes directly.');
|
|
1168
|
+
else if (givesRoll && rolledAtAll)
|
|
1169
|
+
warn('W-OUT-OF-STOCK', `${where}.outcomes`, `${label(b)} gives a roll of a box, which only its sale in one step gives: ${b.rollsOn ? 'it rolls on an NFT, so it is opened' : `${[...new Set(rolls.filter((r) => r.box === b.key).map(roller))].join(', ')} roll${rolls.filter((r) => r.box === b.key).length === 1 ? 's' : ''} it`}, and in an order the roll is out of stock: that order gives nothing${outs.length === 1 && outcomeParts(outs[0]).length === 1 ? ' (with nothing else in it, the roll is refused, SoldOut)' : ''}.`, 'Give the roll from a box nothing rolls, or give the box’s prizes directly.');
|
|
1170
|
+
const tok = token(b.currency, `${where}.currency`);
|
|
1171
|
+
const price = units(b.price, tok, `${where}.price`, `${label(b)}’s price`);
|
|
1172
|
+
prices(b, tok, where);
|
|
1173
|
+
perWalletWindow(b, where);
|
|
1174
|
+
split(b.split, tok, `${where}.split`, { what: label(b), chosen: true });
|
|
1175
|
+
feeNote(price, tok, `${where}.price`, `opening of ${label(b)}`);
|
|
1176
|
+
rule(b.gate, `${where}.gate`, label(b));
|
|
1177
|
+
const outcomes = b.outcomes ?? [];
|
|
1178
|
+
// A roll on an NFT (`rollsOn`): what its outcomes do to it (order.rs, settle.rs).
|
|
1179
|
+
// Earned rolls: a mission's or a recipe's, of this box or of a box that rolls it.
|
|
1180
|
+
const rolledBy = [...new Set(rolls.filter((r) => r.box === b.key && ['mission', 'recipe'].includes(r.route)).map(roller))];
|
|
1181
|
+
if (b.rollsOn) rule(b.rollsOn, `${where}.rollsOn`, `${label(b)}’s NFT`);
|
|
1182
|
+
for (const [i, o] of outcomes.entries()) {
|
|
1183
|
+
const at = `${where}.outcomes[${i}]`;
|
|
1184
|
+
if ((o.shifts?.length || (o.then && o.then !== 'back')) && !b.rollsOn) refuse('E-ROLL', at, `An outcome of ${label(b)} acts on an NFT, but the box rolls on none.`, 'Add "rollsOn": the rule the NFT it rolls on must pass.');
|
|
1185
|
+
for (const [j, x] of (o.shifts ?? []).entries()) {
|
|
1186
|
+
if (RESERVED_ATTRIBUTES.includes(x.attribute)) refuse('E-ROLL', `${at}.shifts[${j}]`, `${label(b)} moves “${x.attribute}”, a number of the program’s own.`, 'Use a name of the game’s own, such as "level".');
|
|
1187
|
+
timed(x, `${at}.shifts[${j}]`, `${label(b)} moves “${x.attribute}”`, 'E-ROLL');
|
|
1188
|
+
fourByteSeconds(x.by, `${at}.shifts[${j}].by`, { signed: true });
|
|
1189
|
+
fourByteSeconds(x.upTo, `${at}.shifts[${j}].upTo`);
|
|
1190
|
+
}
|
|
1191
|
+
thenCheck(o.then, `${at}.then`, label(b));
|
|
1192
|
+
}
|
|
1193
|
+
if (b.then !== undefined && !b.rollsOn) refuse('E-ROLL', `${where}.then`, `${label(b)} says where the NFT it rolls on goes, but it rolls on none.`, 'Add "rollsOn": the rule the NFT it takes must pass.');
|
|
1194
|
+
thenCheck(b.then, `${where}.then`, label(b));
|
|
1195
|
+
const thenOfOutcome = (o) => o.then ?? b.then;
|
|
1196
|
+
const custodial = outcomes.some((o) => thenOfOutcome(o) && thenOfOutcome(o) !== 'back');
|
|
1197
|
+
// A known limit of the program: a settle hands every account of the instruction to each NFT's move, and the 32 KiB
|
|
1198
|
+
// heap a transaction has by default keeps every copy. A payee the project didn't pick (a holder's share, a buyer's
|
|
1199
|
+
// chosen wallet, or a wallet's) can turn on required memos for their own Token-2022 account at any time, which adds
|
|
1200
|
+
// the memo program and a memo call to every settle, so each is counted as needing one. Measured on devnet's
|
|
1201
|
+
// features: with a hooked coin, three NFTs of stashes and the NFT rolled on
|
|
1202
|
+
// moved (four moves) and three or four such payees don't fit; four moves and two payees, or three moves and four
|
|
1203
|
+
// payees, fit (about 31.5 KiB and 30.8 KiB); without a hook every shape fits. With a 256 KiB heap frame, which loot's
|
|
1204
|
+
// senders ask for (`settleHeapFrame`), every one of them lands.
|
|
1205
|
+
const moves = Math.max(0, ...outcomes.map((o) => outcomeParts(o).filter((x) => x.gives === 'stash' || x.gives === 'nft').length + (thenOfOutcome(o) && !['back', 'burn'].includes(thenOfOutcome(o)) ? 1 : 0)));
|
|
1206
|
+
const payees = sharesOf(b.split).filter((x) => ['holder', 'chosen', 'wallet'].includes(x.kind)).length;
|
|
1207
|
+
// How xsolana's senders send a settle (js/play, the keeper's sweep).
|
|
1208
|
+
const sends = 'xsolana’s senders (js/play, the keeper’s sweep) ask for a 256 KiB heap frame, and send a settle too big for a legacy transaction as a v0 one with a lookup table (W-SETTLE-V0): it lands';
|
|
1209
|
+
if (tok?.powers?.transferHook && moves >= 4 && payees >= 3)
|
|
1210
|
+
warn('W-BOX-HEAP', `${where}.outcomes`, `${label(b)}’s settle moves ${moves} NFTs (three of stashes and the one it rolls on) and pays ${payees} holders’ or wallets’ shares in a coin with a transfer hook: it doesn’t fit the 32 KiB heap a transaction has by default once any of those payees turns on required memos for their own account, which they can do at any time: each NFT moved gets its own copy of the settle's accounts, and the copies outgrow the heap. ${sends}. One sent without the frame (another app’s) fails, and as a rolled order is never refunded, its player then settles by putting one won NFT back in its stash, where it waits for the owner.`, 'Give at most two NFTs of stashes an outcome, at most two holders’ or wallets’ shares, or a coin with no transfer hook.');
|
|
1211
|
+
// Every box that settles (one that rolls, rolls on an NFT, or that a grant, a place, a contest, an auction, a mission
|
|
1212
|
+
// or a recipe rolls): its settle as xsolana's senders build it (`settleSize`). Refused only where it can't land at all:
|
|
1213
|
+
// more accounts than a transaction locks even with none of a compressed NFT's proof, or more compute than a
|
|
1214
|
+
// transaction may have. A settle that may go past 64 accounts (a compressed NFT's proof can be 17 accounts, a payee
|
|
1215
|
+
// may turn on memos) is warned of, and one bigger than a legacy transaction goes as v0 with a lookup table its
|
|
1216
|
+
// sender makes. Two shapes outgrow the 32 KiB heap on a compressed subject (three Core NFTs of stashes; two and four
|
|
1217
|
+
// holders' or wallets' shares): with the 256 KiB heap frame xsolana's senders ask for they land (48 and 58 accounts).
|
|
1218
|
+
if (!boxBuys(b, m) || rolls.some((r) => r.box === b.key)) {
|
|
1219
|
+
const sizes = (outcomes.length ? outcomes : [{}]).map((o, i) => ({ i, ...settleAccounts(b, o, m), least: settleAccounts(b, o, m, { proof: 0 }).count, units: settleUnits(b, o, m), ...settleSize(b, o, m) }));
|
|
1220
|
+
const at = (x) => `${where}.outcomes${outcomes.length ? `[${x.i}]` : ''}`;
|
|
1221
|
+
// As v0 (any settle past a legacy transaction), the transaction locks its lookup table and the table's program
|
|
1222
|
+
// too: it deactivates the table (TABLE_LOCKS more).
|
|
1223
|
+
const asSent = (n, x) => n + (x.legacy > MAX_TRANSACTION_BYTES || n > LEGACY_MOST_ACCOUNTS ? TABLE_LOCKS : 0);
|
|
1224
|
+
const certain = sizes.find((x) => asSent(x.least, x) > ACCOUNT_LOCKS);
|
|
1225
|
+
const heavy = sizes.find((x) => x.units > MAX_COMPUTE_UNITS);
|
|
1226
|
+
const worst = sizes.reduce((a, x) => (asSent(x.count + (x.memo ? 1 : 0), x) > asSent(a.count + (a.memo ? 1 : 0), a) ? x : a), sizes[0]);
|
|
1227
|
+
const plain = worst.count + (worst.memo ? 1 : 0);
|
|
1228
|
+
const total = asSent(plain, worst);
|
|
1229
|
+
if (certain)
|
|
1230
|
+
refuse('E-TX-SIZE', at(certain), `${label(b)}’s settle of its outcome ${certain.i} takes ${certain.least} accounts at the least, and as a v0 transaction its lookup table and the table’s program two more: ${asSent(certain.least, certain)}, more than the ${ACCOUNT_LOCKS} a transaction can lock, so nobody could settle it, and a rolled order is never refunded.`, 'Give the outcome fewer prizes or fewer holders’ or wallets’ shares.');
|
|
1231
|
+
else if (heavy)
|
|
1232
|
+
refuse('E-TX-SIZE', at(heavy), `${label(b)}’s settle of its outcome ${heavy.i} would take about ${heavy.units.toLocaleString('en-US')} compute units, more than the ${MAX_COMPUTE_UNITS.toLocaleString('en-US')} a transaction can have, so nobody could settle it, and a rolled order is never refunded.`, 'Give the outcome fewer NFTs (a pNFT’s move takes the most) or fewer holders’ or wallets’ shares.');
|
|
1233
|
+
else if (total > ACCOUNT_LOCKS) {
|
|
1234
|
+
const tipped = worst.count + TABLE_LOCKS <= ACCOUNT_LOCKS && worst.memo;
|
|
1235
|
+
const back = outcomeParts(outcomes[worst.i] ?? {}).some((x) => x.gives === 'stash' || x.gives === 'nft');
|
|
1236
|
+
warn('W-BOX-ACCOUNTS', at(worst), `${label(b)}’s settle of its outcome ${worst.i} takes ${worst.count} accounts${worst.compressedSubject || worst.least < worst.count ? ` with a compressed NFT’s proof (it can be ${LONGEST_PROOF} accounts)` : ''}${worst.memo ? `, and the memo program is one more once any of its ${worst.payees} payee${worst.payees === 1 ? '' : 's'} the project didn’t pick (a holder’s share, a buyer’s chosen wallet, a wallet’s) turns on required memos for their own account, which they can do at any time` : ''}; sent as a v0 transaction, its lookup table and the table’s program are two more: ${total}. A transaction locks at most ${ACCOUNT_LOCKS}, so nobody can settle it then${tipped ? ' (until a payee turns memos on, it lands)' : ''}, and a rolled order is never refunded. ${back ? 'Its player can still settle it by putting every NFT won back in its stash.' : 'What it won can’t be put back to make room: the order, the NFT and the price stay where they are.'}`, `Give the outcome fewer prizes or fewer holders’ or wallets’ shares${worst.compressedSubject ? ', roll on the game’s own collectibles, or check that the collection’s trees keep a deeper canopy (a shorter proof)' : ''}.`);
|
|
1237
|
+
} else if (worst.compressedSubject && sizes.some((x) => x.corePicks >= 3 || (x.picks >= 2 && x.payees >= 4)))
|
|
1238
|
+
warn('W-BOX-HEAP', `${where}.rollsOn`, `${label(b)} rolls on an NFT of a collection that may be compressed, whose proof every NFT a settle moves is handed: with ${sizes.some((x) => x.corePicks >= 3) ? 'three NFTs of stashes' : 'two NFTs of stashes and four holders’ or wallets’ shares'} it doesn’t fit the 32 KiB heap a transaction has by default. ${sends}. One sent without the frame (another app’s) fails, and as a rolled order is never refunded, its player then settles by putting every NFT won back in its stash.`, 'Give an outcome fewer NFTs of stashes or fewer holders’ or wallets’ shares, or roll on the game’s own collectibles.');
|
|
1239
|
+
const big = sizes.reduce((a, x) => (x.legacy > a.legacy ? x : a), sizes[0]);
|
|
1240
|
+
if (!certain && !heavy && total <= ACCOUNT_LOCKS && big.legacy > MAX_TRANSACTION_BYTES)
|
|
1241
|
+
warn('W-SETTLE-V0', at(big), `${label(b)}’s settle of its outcome ${big.i} takes about ${big.legacy} bytes as a legacy transaction, more than the ${MAX_TRANSACTION_BYTES} one holds (${big.locks} accounts, ${big.locks + TABLE_LOCKS} as v0 with its table). xsolana’s senders send it as a v0 transaction that loads its accounts from an address lookup table: js/play from the project’s standing table, which going live makes (one approval; a table of the player’s own, about ${(Number(big.tableRent) / 1e9).toFixed(4)} SOL of rent back about two minutes later, only for accounts it lacks), the keeper’s sweep from its own payer. A sender of legacy transactions only (another app’s) can’t settle it, and a rolled order is never refunded.`, 'To keep it a legacy transaction, give the outcome fewer prizes or fewer holders’ or wallets’ shares.');
|
|
1242
|
+
}
|
|
1243
|
+
if (b.rollsOn && custodial)
|
|
1244
|
+
warn('W-ROLL-CUSTODY', `${where}.rollsOn`, `${label(b)} can ${outcomes.some((o) => thenOfOutcome(o) === 'burn') ? 'burn' : outcomes.some((o) => thenOfOutcome(o)?.stash) ? 'keep' : 'give away'} the NFT it rolls on: the project holds the NFT from the moment it is bought until the roll is settled, so nobody can move it away from a bad roll. A player who brings their own NFT must agree to that.`);
|
|
1245
|
+
// Every outcome sends the NFT away: the NFT is the box's price (a trade-in).
|
|
1246
|
+
if (b.rollsOn && outcomes.length && outcomes.every((o) => thenOfOutcome(o) && thenOfOutcome(o) !== 'back'))
|
|
1247
|
+
warn('W-NFT-PRICE', `${where}.rollsOn`, `${label(b)} takes the NFT it rolls on as its price: whatever it gives, the NFT ${outcomes.every((o) => thenOfOutcome(o) === 'burn') ? 'is burned' : 'is not given back'}. It opens with an order and settles after (\`open_box\`, \`settle\`), even with one outcome.`);
|
|
1248
|
+
// Free and for sale, anyone could open it again and again and empty what it gives: strangers take the owner's prizes.
|
|
1249
|
+
if (rolledBy.length && b.active !== false && price === 0n)
|
|
1250
|
+
refuse('E-ROLL', `${where}.active`, `${label(b)} is rolled by ${rolledBy.join(', ')}, and is for sale for free: anyone could open it directly, as often as they like, and skip the mission or the recipe.`, 'Set "active": false (a box only missions or recipes roll is never sold), or give it a price.');
|
|
1251
|
+
else if (rolledBy.length && b.active !== false)
|
|
1252
|
+
warn('W-ROLL', `${where}.active`, `${label(b)} is rolled by ${rolledBy.join(', ')}, and is also for sale: anyone may buy a roll of it instead of earning one.`, 'Set "active": false if it should only be earned.');
|
|
1253
|
+
// A prize's roll (a grant, a lottery's place, a contest's winners, an auction's lot) is made for a winner, on nothing.
|
|
1254
|
+
const prizedBy = rolls.filter((r) => r.box === b.key && !['mission', 'recipe'].includes(r.route)).map(roller);
|
|
1255
|
+
if (prizedBy.length && b.active !== false && !b.shopOf)
|
|
1256
|
+
warn('W-ROLL-PRIZE', `${where}.active`, `${label(b)} is a prize of ${[...new Set(prizedBy)].join(', ')}, and is for sale too: anyone can open it for its price${price === 0n ? ' (free)' : ''}, and each opening takes from what the prize gives (a stash’s NFTs, a pool’s prizes).`, 'Set "active": false for a box only prizes roll.');
|
|
1257
|
+
if (prizedBy.length && b.rollsOn)
|
|
1258
|
+
warn('W-ROLL-PRIZE', `${where}.rollsOn`, `${label(b)} rolls on an NFT, but ${[...new Set(prizedBy)].join(', ')} give${prizedBy.length === 1 ? 's' : ''} a roll of it on nothing: its outcomes are given, and their shifts and where they send an NFT are not.`, 'Give prizes a box with no "rollsOn".');
|
|
1259
|
+
if (price === 0n && !rolledBy.length && b.active !== false)
|
|
1260
|
+
warn('W-FREE', `${where}.price`, `${label(b)} is free: anyone, a bot included, can open it again and again for only the fees${outcomes.flatMap(outcomeParts).some((o) => o.gives === 'pay') ? ', and drain the pools its prizes come from' : ''}.`, 'Give it a price, a gate, or hand out openings through a grant.');
|
|
1261
|
+
const withChance = outcomes.filter((o) => o.chance !== undefined).length;
|
|
1262
|
+
const withWeight = outcomes.filter((o) => o.weight !== undefined).length;
|
|
1263
|
+
if (withChance && withWeight) refuse('E-ODDS', `${where}.outcomes`, `${label(b)} mixes chances and weights, so its odds cannot be read.`, 'Give every outcome a chance (percents adding up to 100), or every one a weight.');
|
|
1264
|
+
else if (withChance + withWeight < outcomes.length) refuse('E-ODDS', `${where}.outcomes`, `Some outcomes of ${label(b)} have no chance.`, 'Give every outcome a chance (percents adding up to 100) or a weight.');
|
|
1265
|
+
else if (withChance) {
|
|
1266
|
+
const total = outcomes.reduce((n, o) => n + o.chance, 0);
|
|
1267
|
+
if (Math.abs(total - 100) > 1e-9) refuse('E-ODDS', `${where}.outcomes`, `${label(b)}’s chances add up to ${+total.toFixed(6)}%, not 100%.`, 'Make them add up to 100.');
|
|
1268
|
+
if (outcomes.some((o) => Math.abs(o.chance * 10_000 - Math.round(o.chance * 10_000)) > 1e-6)) refuse('E-ODDS', `${where}.outcomes`, `A chance in ${label(b)} has more than four decimals.`, 'Round it to four decimals, or use weights.');
|
|
1269
|
+
}
|
|
1270
|
+
const parts = outcomes.flatMap(outcomeParts);
|
|
1271
|
+
// A paid box with more than one outcome that hands out collectibles a player can resell: a loot box.
|
|
1272
|
+
if (price > 0n && outcomes.length > 1 && parts.some((o) => (o.gives === 'item' && !m.item.get(o.item)?.soulbound && m.item.get(o.item)?.kind !== 'normal') || o.gives === 'nft')) randomItems = true;
|
|
1273
|
+
// A stash's NFTs are picked at random and can be resold: a paid box of one is a loot box.
|
|
1274
|
+
if (price > 0n && b.active !== false && parts.some((o) => o.gives === 'stash')) randomItems = true;
|
|
1275
|
+
const prizes = [];
|
|
1276
|
+
for (const [i, outcome] of outcomes.entries()) {
|
|
1277
|
+
const at = `${where}.outcomes[${i}]`;
|
|
1278
|
+
if (outcome.gives === 'bundle' && !(outcome.prizes ?? []).length) refuse('E-ODDS', `${at}.prizes`, `A bundle of ${label(b)} gives nothing.`, 'List up to three prizes in "prizes".');
|
|
1279
|
+
for (const [k, o] of outcomeParts(outcome).entries()) {
|
|
1280
|
+
const at = outcome.gives === 'bundle' ? `${where}.outcomes[${i}].prizes[${k}]` : `${where}.outcomes[${i}]`;
|
|
1281
|
+
// A prize line's own kinds: tokens minted, a named NFT, a roll of a box (prize.rs).
|
|
1282
|
+
if (o.gives === 'mint' || o.gives === 'nft' || o.gives === 'roll') prizeCheck(o.gives === 'mint' ? { gives: 'tokens', from: 'mint', currency: o.currency, amount: o.amount } : o, at, `An outcome of ${label(b)}`, 'E-ODDS');
|
|
1283
|
+
if (o.gives === 'item') {
|
|
1284
|
+
if (!o.item) refuse('E-ODDS', at, `An outcome of ${label(b)} gives an item but does not say which.`, 'Add "item".');
|
|
1285
|
+
else item(o.item, `${at}.item`);
|
|
1286
|
+
}
|
|
1287
|
+
if (o.gives === 'pay') {
|
|
1288
|
+
chance = true;
|
|
1289
|
+
const p = o.pool ? pool(o.pool, `${at}.pool`) : refuse('E-ODDS', at, `An outcome of ${label(b)} pays from a pool but does not say which.`, 'Add "pool".');
|
|
1290
|
+
if (p) {
|
|
1291
|
+
const amount = units(o.amount, m.tokens.get(p.currency), `${at}.amount`, `${label(b)}’s prize`);
|
|
1292
|
+
if (amount === 0n) refuse('E-ODDS', `${at}.amount`, `An outcome of ${label(b)} pays nothing (BadEntries).`, 'Give it an amount above 0, or make it "nothing".');
|
|
1293
|
+
// In base units, exact (null when it is not an amount, refused above).
|
|
1294
|
+
prizes.push({ pool: p, amount });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (o.gives === 'stash') {
|
|
1298
|
+
if (!o.stash) refuse('E-ODDS', at, `An outcome of ${label(b)} gives a stash’s NFT but does not say which stash.`, 'Add "stash".');
|
|
1299
|
+
else if (!m.stash.get(o.stash)) refuse('E-REF', `${at}.stash`, `There is no stash “${o.stash}”.`, 'Add it to "stashes", or name one that is there.');
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// The oracle's answer to a roll carries the order, the box, its ticket, an account for each outcome that wins
|
|
1304
|
+
// something, and with prizes the token programs, the game and a mint and a vault for each pool (catalog.rs).
|
|
1305
|
+
if (boxRolls(b, m)) {
|
|
1306
|
+
const named = parts.filter((o) => ['item', 'pay', 'stash', 'nft'].includes(o.gives)).length;
|
|
1307
|
+
const sources = new Set(parts.filter((o) => ['pay', 'mint'].includes(o.gives)).map((o) => (o.gives === 'mint' ? `mint:${o.currency ?? m.currencyKey}` : o.pool))).size;
|
|
1308
|
+
const callback = 3 + named + (sources ? 3 + 2 * sources : 0);
|
|
1309
|
+
if (callback > LIMITS.callbackAccounts) refuse('E-ODDS', `${where}.outcomes`, `${label(b)}’s roll would hand the oracle ${callback} accounts; its answer carries at most ${LIMITS.callbackAccounts} (TooManyAccounts).`, 'Pay prizes from fewer pools, or have fewer outcomes.');
|
|
1310
|
+
}
|
|
1311
|
+
// The player's purchase must be sendable, with the compute budget their wallet adds: js/play sends one
|
|
1312
|
+
// too big for a legacy transaction as v0 with a lookup table, which locks the table and its program too.
|
|
1313
|
+
if (b.active !== false && !refusals.some((x) => x.where.startsWith(where))) {
|
|
1314
|
+
// A fixed pack too big to buy in one legacy transaction is opened instead (js/play): the oracle answers it.
|
|
1315
|
+
const bought = boxBuys(b, m) ? buySize(b, m, { detail: true }) : null;
|
|
1316
|
+
const buys = Boolean(bought) && (drawsOf(b) === 1 || (bought.bytes <= MAX_TRANSACTION_BYTES && bought.locks <= ACCOUNT_LOCKS));
|
|
1317
|
+
const { bytes, locks } = buys ? bought : openBoxSize(b, m, { detail: true });
|
|
1318
|
+
const verb = buys ? 'buy' : 'open';
|
|
1319
|
+
if (bytes > MAX_TRANSACTION_BYTES && locks + TABLE_LOCKS > ACCOUNT_LOCKS)
|
|
1320
|
+
refuse('E-TX-SIZE', where, `${buys ? 'Buying' : 'Opening'} ${label(b)} would take about ${bytes} bytes and ${locks} accounts, ${locks + TABLE_LOCKS} as a v0 transaction with a lookup table: more than the ${ACCOUNT_LOCKS} a transaction can lock, so no player could ${verb} it.`, 'Give it fewer outcomes that name an item or a pool, pay its prizes (and those of a box it rolls) from fewer pools, or drop its per-wallet limit.');
|
|
1321
|
+
else if (bytes > MAX_TRANSACTION_BYTES)
|
|
1322
|
+
warn('W-TX-V0', where, `${buys ? 'Buying' : 'Opening'} ${label(b)} takes about ${bytes} bytes with the compute budget a wallet adds, more than the ${MAX_TRANSACTION_BYTES} a legacy transaction holds: js/play sends it as a v0 transaction that loads its accounts from the project’s standing lookup table, which going live makes (one approval; going live is refused without it, E-NO-TABLE). A wallet or app that sends only legacy transactions can’t ${verb} it.`, 'To keep it a legacy transaction, give it fewer outcomes that name an item or a pool, or pay its prizes from fewer pools.');
|
|
1323
|
+
}
|
|
1324
|
+
for (const [i, o] of (b.odds ?? []).entries()) {
|
|
1325
|
+
const at = `${where}.odds[${i}]`;
|
|
1326
|
+
if (o.outcome >= outcomes.length) refuse('E-ODDS', `${at}.outcome`, `${label(b)}’s odds raise outcome ${o.outcome}, but it has ${outcomes.length} (the first is 0).`, 'Point it at one of its outcomes.');
|
|
1327
|
+
rule(o.when, `${at}.when`, `${label(b)}’s odds`);
|
|
1328
|
+
effect(o.add, `${at}.add`, `${label(b)}’s odds`);
|
|
1329
|
+
}
|
|
1330
|
+
if ((b.odds ?? []).length && outcomes.length < 2) warn('W-ODDS', `${where}.odds`, `${label(b)} has one outcome, so odds change nothing.`);
|
|
1331
|
+
// A fixed prize is in stock only while its pool holds all of it (order.rs): a prize won leaves the pool for the
|
|
1332
|
+
// game's vault at the roll.
|
|
1333
|
+
for (const z of prizes) {
|
|
1334
|
+
if (z.amount === null) continue;
|
|
1335
|
+
const need = seedNeeds.get(z.pool.key) ?? { pool: z.pool, amount: 0n, box: b, boxes: new Set() };
|
|
1336
|
+
need.boxes.add(b);
|
|
1337
|
+
if (z.amount > need.amount) Object.assign(need, { amount: z.amount, box: b });
|
|
1338
|
+
seedNeeds.set(z.pool.key, need);
|
|
1339
|
+
}
|
|
1340
|
+
if (b.stockOdds && !parts.some((o) => o.gives === 'item' && m.item.get(o.item)?.supply > 0))
|
|
1341
|
+
warn('W-ODDS', `${where}.stockOdds`, `${label(b)} has odds that shrink with stock, but none of its items has a limited supply, so nothing changes.`, 'Give its items a supply, or turn stockOdds off.');
|
|
1342
|
+
// What it does once an outcome runs out (order.rs `WhenOut`): only an open of a box that rolls reads it.
|
|
1343
|
+
if (b.whenOut !== undefined && boxBuys(b, m) && drawsOf(b) === 1)
|
|
1344
|
+
warn('W-WHEN-OUT', `${where}.whenOut`, `${label(b)} sells in one step, all of it or nothing, so "whenOut" changes nothing.`, 'Leave "whenOut" out.');
|
|
1345
|
+
if (b.whenOut === 'drawLeft' && boxRolls(b, m)) {
|
|
1346
|
+
// The rarest outcome that gives something, against the likeliest outcome that can run out.
|
|
1347
|
+
const w = weights(b);
|
|
1348
|
+
const total = w.reduce((a, x) => a + x, 0) || 1;
|
|
1349
|
+
const runsOut = (o) => outcomeParts(o).some((x) => x.gives === 'pay' || x.gives === 'stash' || x.gives === 'nft' || (x.gives === 'item' && Number(m.item.get(x.item)?.supply ?? 0) > 0));
|
|
1350
|
+
const giving = outcomes.map((o, i) => i).filter((i) => outcomeParts(outcomes[i]).length);
|
|
1351
|
+
const rare = giving.reduce((r, i) => (r === -1 || w[i] < w[r] ? i : r), -1);
|
|
1352
|
+
const common = outcomes.map((o, i) => i).filter((i) => i !== rare && runsOut(outcomes[i]) && w[i] > w[rare]).reduce((c, i) => (c === -1 || w[i] > w[c] ? i : c), -1);
|
|
1353
|
+
if (rare >= 0 && common >= 0) {
|
|
1354
|
+
const pct = (x) => Math.round(x * 10_000) / 100;
|
|
1355
|
+
warn('W-WHEN-OUT', `${where}.whenOut`, `${label(b)} draws among what is left once an outcome runs out ("whenOut": "drawLeft"): once outcome ${common} runs out, outcome ${rare}’s chance goes from ${pct(w[rare] / total)}% to ${pct(w[rare] / (total - w[common]))}%. Anyone who reads the stock can buy until outcome ${common} is gone and then open for outcome ${rare} at better odds.`, 'Keep "stopSales" (the default) when a rare outcome matters: the box stops selling instead, and every roll stays on the odds written.');
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
// A fixed prize a pool can't cover is out of stock (order.rs): a draw that lands on it is not made and gives back its
|
|
1360
|
+
// worth, a box with nothing else in stock is refused (SoldOut), and a sale in one step is refused (PrizeNotCovered).
|
|
1361
|
+
for (const { pool: p, amount: most, box, boxes } of seedNeeds.values()) {
|
|
1362
|
+
const tok = m.tokens.get(p.currency);
|
|
1363
|
+
const sym = tok?.symbol ?? p.currency;
|
|
1364
|
+
// Both in base units, exact: a seed that is not an amount is refused with its pool.
|
|
1365
|
+
const seed = quietUnits(p.seed, tok);
|
|
1366
|
+
if (seed === null || seed >= most) continue;
|
|
1367
|
+
const amount = fromUnits(most, tok?.decimals ?? 0);
|
|
1368
|
+
const others = [...boxes].filter((x) => x !== box).map(label);
|
|
1369
|
+
// A box that stops selling when an outcome is out (the default) needs the prize for this purchase's draws and every
|
|
1370
|
+
// draw still waiting for the oracle before it sells at all.
|
|
1371
|
+
const stopped = [...boxes].filter((x) => x.active !== false && x.whenOut !== 'drawLeft' && boxRolls(x, m));
|
|
1372
|
+
const stops = stopped.length ? ` ${stopped.map(label).join(', ')} stop${stopped.length === 1 ? 's' : ''} selling while an outcome is out ("whenOut": "stopSales"): ${stopped.length === 1 ? 'it isn’t' : 'they aren’t'} sold at all while the pool holds less than the prize for that purchase’s draws and every draw still waiting for the oracle.` : '';
|
|
1373
|
+
warn('W-PRIZE-SEED', `economy.pools.${p.key}.seed`, `“${p.key}” starts with ${fromUnits(seed, tok?.decimals ?? 0)} ${sym}, but a win in ${label(box)} pays ${amount} ${sym}. A fixed prize its pool can’t cover is out of stock: the odds stay as written, so a roll that lands on it before the pool holds ${amount} ${sym} makes no draw and gives back that draw’s worth, no other outcome taking its place (a box with nothing else in stock is refused, SoldOut), and so again whenever a run of wins takes the pool below that${others.length ? ` (${others.join(', ')} pay${others.length === 1 ? 's' : ''} fixed prizes from it too, each out of stock the same way below its own amount)` : ''}.${stops}`, `Seed “${p.key}” with at least ${amount} ${sym} for each purchase that may wait for its roll at once; more rides out a run of wins (the simulation suggests how much). For a prize that grows with sales, use a lottery’s pot instead.`);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ---- a live box's roll rewritten: an edit of the entries, odds, stock odds, `whenOut`, the NFT it rolls on or the fates fails an
|
|
1377
|
+
// order that holds a payment, which is refunded; any other roll waiting (a prize, a mission's chest, a recipe's) rolls on
|
|
1378
|
+
// the box as edited, so an edit never loses what was spent.
|
|
1379
|
+
const published = new Map((economy.live?.published?.boxes ?? []).map((b) => [b.key, b]));
|
|
1380
|
+
const rollOf = (b) => JSON.stringify([b.outcomes ?? [], b.odds ?? [], Boolean(b.stockOdds), b.whenOut ?? 'stopSales', b.rollsOn ?? null, b.then ?? null]);
|
|
1381
|
+
for (const b of economy.boxes ?? []) {
|
|
1382
|
+
const was = published.get(b.key);
|
|
1383
|
+
if (!was || rollOf(was) === rollOf(b)) continue;
|
|
1384
|
+
const routes = [...new Set(rolls.filter((x) => x.box === b.key).map(roller))];
|
|
1385
|
+
warn('W-LIVE-ROLLS', `economy.boxes.${b.key}`, `${label(m.box.get(b.key) ?? b)} is live, and this changes its roll (its outcomes, odds, stock odds, what it does when an outcome runs out, the NFT it rolls on or what becomes of it). A purchase still waiting for its roll fails when the change lands, and gets its payment back${routes.length ? `; a roll won on another route (${routes.join(', ')}) holds no payment and rolls on the box as it is then` : '; a roll won on another route holds no payment and rolls on the box as it is then'}.`, 'Publish the change only once no roll of it waits (each settled or refunded), or open a new box with the new roll and point the routes at it.');
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// ---- payments
|
|
1389
|
+
for (const p of m.payments) {
|
|
1390
|
+
const where = `economy.payments.${p.key}`;
|
|
1391
|
+
const tok = token(p.currency, `${where}.currency`);
|
|
1392
|
+
const amount = units(p.amount, tok, `${where}.amount`, `${label(p)}’s amount`);
|
|
1393
|
+
if (amount === 0n) refuse('E-PRICE', `${where}.amount`, `${label(p)} is a payment of 0 (ZeroAmount).`, 'Give it an amount above 0.');
|
|
1394
|
+
// A payment's split is the payer's own (`pay` takes it from them each time), so they may name one wallet in it too.
|
|
1395
|
+
split(p.split, tok, `${where}.split`, { what: label(p), chosen: 'wallet' });
|
|
1396
|
+
feeNote(amount, tok, `${where}.amount`, `payment of ${label(p)}`);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ---- recipes (craft.rs)
|
|
1400
|
+
for (const r of m.recipes) {
|
|
1401
|
+
const where = `economy.recipes.${r.key}`;
|
|
1402
|
+
const ins = r.in ?? [];
|
|
1403
|
+
// Each kind of line has its cap, a collectible's life adding its own needs and raises.
|
|
1404
|
+
const lines = (() => {
|
|
1405
|
+
try {
|
|
1406
|
+
return recipeArgs(r, m);
|
|
1407
|
+
} catch {
|
|
1408
|
+
return { inputs: ins, outputs: r.out ?? [], needs: r.needs ?? [], keeps: r.keeps ?? [], raises: r.raises ?? [] };
|
|
1409
|
+
}
|
|
1410
|
+
})();
|
|
1411
|
+
for (const [kind, words] of [['inputs', 'lines in'], ['outputs', 'lines out'], ['needs', 'needs'], ['keeps', 'numbers it carries over'], ['raises', 'raises']])
|
|
1412
|
+
tooMany(lines[kind].length, LIMITS.recipeLines, 'E-RECIPE', where, `${label(r)} has ${lines[kind].length} ${words}`, 'BadRecipe', 'Split it into two recipes.');
|
|
1413
|
+
const outs = r.out ?? [];
|
|
1414
|
+
if (!ins.length && !outs.length) warn('W-RECIPE', where, `${label(r)} takes nothing and gives nothing: crafting it only charges the player fee.`, 'Add what it takes and what it gives.');
|
|
1415
|
+
perWalletWindow(r, where);
|
|
1416
|
+
let taken = 0;
|
|
1417
|
+
const kept = [];
|
|
1418
|
+
for (const [i, line] of ins.entries()) {
|
|
1419
|
+
const at = `${where}.in[${i}]`;
|
|
1420
|
+
if (line.takes === 'pay') {
|
|
1421
|
+
const tok = token(line.currency, `${at}.currency`);
|
|
1422
|
+
const amount = units(line.amount, tok, `${at}.amount`, `What ${label(r)} takes`);
|
|
1423
|
+
if (amount === 0n) refuse('E-RECIPE', `${at}.amount`, `${label(r)} takes an amount of 0.`, 'Give it an amount above 0.');
|
|
1424
|
+
feeNote(amount, tok, `${at}.amount`, `craft of ${label(r)}`);
|
|
1425
|
+
if (!line.to) refuse('E-RECIPE', `${at}.to`, `${label(r)} takes ${tok?.symbol ?? 'a token'} but does not say where it goes.`, 'Add "to": "burn", "pool:<key>", "wallet:<address>", "account:<token account>" or "holder:<address>".');
|
|
1426
|
+
else split([{ to: line.to, percent: 100 }], tok, `${at}`, { what: label(r) });
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
if (line.takes === 'trade') {
|
|
1430
|
+
if (line.item) refuse('E-RECIPE', `${at}.item`, `${label(r)} trades in an NFT of a collection, not an item.`, 'Use "takes": "burn" for one of the project’s items, or name the "collection".');
|
|
1431
|
+
if (line.collection && !isAddress(line.collection)) refuse('E-REF', `${at}.collection`, `${label(r)} trades in an NFT of “${line.collection}”, which is not an address.`, 'Write the Core collection’s address, or the Token Metadata collection mint.');
|
|
1432
|
+
if (!line.collection && !m.collection) refuse('E-RECIPE', `${at}.collection`, `${label(r)} trades in an NFT of the project’s own collection, but the project makes none.`, 'Name the "collection" it takes.');
|
|
1433
|
+
if (line.then === 'treasury' || line.then?.giveTo) warn('W-NFT-PRICE', `${at}.then`, `${label(r)} hands each NFT traded in to ${line.then === 'treasury' ? 'the treasury' : line.then.giveTo}, straight from the player.`);
|
|
1434
|
+
thenCheck(line.then, `${at}.then`, label(r));
|
|
1435
|
+
kept.push(false);
|
|
1436
|
+
taken += 1;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
if (line.takes === 'show') {
|
|
1440
|
+
if (line.item) refuse('E-RECIPE', `${at}.item`, `${label(r)} shows an NFT of a collection, not an item.`, 'Use "takes": "use" for one of the game’s items, or name the "collection".');
|
|
1441
|
+
kept.push(true);
|
|
1442
|
+
taken += 1;
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
const it = line.item ? item(line.item, `${at}.item`) : refuse('E-RECIPE', at, `A line of ${label(r)} hands something in but does not say which item.`, 'Add "item".');
|
|
1446
|
+
if (!it) continue;
|
|
1447
|
+
if (it.kind === 'normal') refuse('E-RECIPE', `${at}.item`, `${label(r)} takes ${label(it)}, a normal item: only collectibles can be handed in.`, `Make ${label(it)} a collectible.`);
|
|
1448
|
+
if (line.takes === 'staked') {
|
|
1449
|
+
// The line names its farm (craft.rs, `Input::Staked { item, farm }`): the craft takes the piece only from its seat
|
|
1450
|
+
// there, a farm of collectibles that holds or freezes it (a watched one could be sold while staked).
|
|
1451
|
+
const f = line.farm === undefined ? null : m.farm.get(line.farm);
|
|
1452
|
+
// The recipe's own raises on it, and its life's (uses, a cooldown: `lifeLines`).
|
|
1453
|
+
const raised = [...(r.raises ?? []).filter((x) => x.place === taken).map((x) => x.key), ...lifeLines(r, m).raises.filter((x) => x.place === taken).map((x) => x.shift.key)];
|
|
1454
|
+
const candidates = m.farms.filter((x) => x.stakes === 'collectibles' && (x.custody ?? 'escrow') !== 'watch' && (!x.item || x.item === it.key));
|
|
1455
|
+
if (line.farm === undefined)
|
|
1456
|
+
refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} only while it is staked, but does not say in which farm: the program takes the piece only from its seat in the farm the line names.`, candidates.length ? `Add "farm": ${candidates.map((x) => `"${x.key}"`).join(' or ')}.` : `Add a farm of collectibles that takes ${label(it)} with "custody": "escrow" or "freeze", and name it in "farm".`);
|
|
1457
|
+
else if (!f) refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} staked in “${line.farm}”, which is not a farm of this spec.`, candidates.length ? `Use ${candidates.map((x) => `"${x.key}"`).join(' or ')}.` : 'Fix the name, or add the farm.');
|
|
1458
|
+
else if (f.stakes !== 'collectibles') refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} staked in ${label(f)}, a farm of tokens: a collectible is staked only in a farm of collectibles.`, candidates.length ? `Use ${candidates.map((x) => `"${x.key}"`).join(' or ')}.` : `Add a farm of collectibles that takes ${label(it)}.`);
|
|
1459
|
+
else if (f.item && f.item !== it.key) refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} staked in ${label(f)}, which stakes only ${label(m.item.get(f.item) ?? { key: f.item })}: no ${label(it)} is ever staked there.`, candidates.length ? `Use ${candidates.map((x) => `"${x.key}"`).join(' or ')}.` : `Let ${label(f)} take ${label(it)}.`);
|
|
1460
|
+
else if ((f.custody ?? 'escrow') === 'watch') refuse('E-RECIPE', `${at}.farm`, `${label(r)} takes ${label(it)} staked in ${label(f)}, which watches NFTs left free in their wallets: one could be sold while staked, so the craft takes a staked piece only from a farm that escrows or freezes it.`, `Give ${label(f)} "custody": "escrow" or "freeze", or name another farm.`);
|
|
1461
|
+
// A raise on the piece must move the lock its farm reads (BadRecipe): a lock moved where the farm doesn't read it holds nothing.
|
|
1462
|
+
else if (raised.length && !raised.includes(f.lockAttribute))
|
|
1463
|
+
refuse('E-RECIPE', `${at}.farm`, `${label(r)} raises ${raised.map((k) => `“${k}”`).join(', ')} on ${label(it)} staked in ${label(f)}, but ${f.lockAttribute ? `that farm keeps its lock under “${f.lockAttribute}”` : 'that farm keeps no lock on the NFT ("lockAttribute")'}: a recipe that raises anything on a staked piece must move the lock its farm reads, or the program refuses the craft (BadRecipe).`, f.lockAttribute ? `Raise "${f.lockAttribute}" on collectible ${taken} too, or take it with "takes": "use" to raise a stat and leave its lock alone.` : `Give ${label(f)} "lockAttribute" and raise it here, or take it with "takes": "use" to raise a stat and leave its lock alone.`);
|
|
1464
|
+
else if (!f.lockAttribute) warn('W-STAKED', `${at}.farm`, `${label(r)} takes ${label(it)} only while it is staked in ${label(f)}, which keeps no lock on the NFT ("lockAttribute"): nothing the recipe does can keep it staked after.`, `Give ${label(f)} a "lockAttribute", and raise it in the recipe so the pieces stay staked after the craft.`);
|
|
1465
|
+
}
|
|
1466
|
+
if (line.takes === 'burn' && it.soulbound) warn('W-RECIPE', `${at}.item`, `${label(r)} burns ${label(it)}, which is soulbound and can never be burned, so nobody could ever craft it.`, 'Use it and keep it ("takes": "use"), or make the item not soulbound.');
|
|
1467
|
+
taken += line.takes === 'burn' ? line.count ?? 1 : 1;
|
|
1468
|
+
kept.push(...Array(line.takes === 'burn' ? line.count ?? 1 : 1).fill(line.takes !== 'burn'));
|
|
1469
|
+
}
|
|
1470
|
+
if (taken > LIMITS.collectiblesPerCraft) refuse('E-RECIPE', `${where}.in`, `${label(r)} takes ${taken} collectibles; one craft takes at most ${LIMITS.collectiblesPerCraft}.`, 'Take fewer, or split it into two recipes.');
|
|
1471
|
+
let made = 0;
|
|
1472
|
+
// Each line is a prize (prize.rs), given in the craft; a roll on a collectible it keeps (`on`) is checked below.
|
|
1473
|
+
for (const [i, line] of outs.entries()) {
|
|
1474
|
+
if (line.gives === 'roll' && line.on !== undefined) continue;
|
|
1475
|
+
const given = prizeCheck(line, `${where}.out[${i}]`, label(r), 'E-RECIPE');
|
|
1476
|
+
if (given?.gives === 'item') made += 1;
|
|
1477
|
+
}
|
|
1478
|
+
// A roll it makes (craft.rs, order.rs): at most one, on a collectible it keeps.
|
|
1479
|
+
const places = ins.flatMap((l) => (l.takes === 'burn' ? Array(l.count ?? 1).fill('burn') : l.takes === 'pay' ? [] : [l.takes]));
|
|
1480
|
+
const rolls = outs.filter((l) => l.gives === 'roll');
|
|
1481
|
+
if (rolls.length > 1) refuse('E-RECIPE', `${where}.out`, `${label(r)} rolls ${rolls.length} boxes: a craft makes one roll.`, 'Keep one roll.');
|
|
1482
|
+
for (const [i, line] of outs.entries()) {
|
|
1483
|
+
if (line.gives !== 'roll') continue;
|
|
1484
|
+
const at = `${where}.out[${i}]`;
|
|
1485
|
+
const b = m.box.get(line.box);
|
|
1486
|
+
if (!b) continue;
|
|
1487
|
+
if (line.on === undefined && b.rollsOn) refuse('E-RECIPE', at, `${label(r)} rolls ${label(b)}, which rolls on an NFT, but does not say on which collectible.`, 'Add "on": the place of a collectible the recipe keeps.');
|
|
1488
|
+
if (line.on !== undefined && !b.rollsOn) refuse('E-RECIPE', `${at}.on`, `${label(r)} rolls ${label(b)} on collectible ${line.on}, but the box rolls on none.`, 'Give the box "rollsOn", or leave "on" out.');
|
|
1489
|
+
if (line.on !== undefined && !kept[line.on]) refuse('E-RECIPE', `${at}.on`, `${label(r)} rolls on collectible ${line.on}, which it ${line.on < taken ? 'burns' : 'does not take'}.`, 'Point it at a collectible the recipe uses, renews or shows.');
|
|
1490
|
+
const custodial = (b.outcomes ?? []).some((o) => o.then && o.then !== 'back');
|
|
1491
|
+
// A roll that moves numbers holds the NFT until it settles, as one that can burn or give it away takes it (craft.rs).
|
|
1492
|
+
const moves = (b.outcomes ?? []).some((o) => (o.shifts ?? []).length);
|
|
1493
|
+
if ((custodial || moves) && places[line.on] === 'show')
|
|
1494
|
+
warn('W-ROLL-CUSTODY', `${at}.on`, `${label(b)} ${custodial ? 'can burn or give away' : 'moves numbers on'} the NFT ${label(r)} shows: the craft hands it to the game until it settles, which only a Core NFT can be (a Token Metadata or compressed one is refused, NeedsCustody).`, 'Roll on one of the game’s items ("use").');
|
|
1495
|
+
}
|
|
1496
|
+
for (const [i, raise] of (r.raises ?? []).entries()) {
|
|
1497
|
+
timed(raise, `${where}.raises[${i}]`, `${label(r)} raises “${raise.key}”`, 'E-RECIPE');
|
|
1498
|
+
fourByteSeconds(raise.by, `${where}.raises[${i}].by`, { signed: true });
|
|
1499
|
+
fourByteSeconds(raise.upTo, `${where}.raises[${i}].upTo`);
|
|
1500
|
+
if (!kept[raise.place]) refuse('E-RECIPE', `${where}.raises[${i}]`, `${label(r)} raises “${raise.key}” on collectible ${raise.place}, which it ${raise.place < taken ? 'burns' : 'does not take'}: only one it keeps can be raised.`, 'Point it at a collectible the recipe uses, renews or shows.');
|
|
1501
|
+
if (RESERVED_ATTRIBUTES.includes(raise.key)) refuse('E-RECIPE', `${where}.raises[${i}].key`, `“${raise.key}” is a number the program writes itself.`, 'Raise a key of the game’s own, such as "level" or "power".');
|
|
1502
|
+
}
|
|
1503
|
+
for (const [i, need] of (r.needs ?? []).entries())
|
|
1504
|
+
if (need.place >= taken) refuse('E-RECIPE', `${where}.needs[${i}]`, `${label(r)} asks something of collectible number ${need.place}, but it only takes ${taken} (the first is 0).`, 'Point it at a collectible the recipe takes.');
|
|
1505
|
+
for (const [i, keep] of (r.keeps ?? []).entries()) {
|
|
1506
|
+
if (keep.from >= taken || keep.to >= made) refuse('E-RECIPE', `${where}.keeps[${i}]`, `${label(r)} carries “${keep.key}” from collectible ${keep.from} to new collectible ${keep.to}, but it takes ${taken} and makes ${made} (the first of each is 0).`, 'Point it at collectibles the recipe takes and makes.');
|
|
1507
|
+
if (RESERVED_ATTRIBUTES.includes(keep.key)) refuse('E-RECIPE', `${where}.keeps[${i}].key`, `“${keep.key}” is a number the program writes itself and cannot be carried over.`, 'Carry a key the game wrote.');
|
|
1508
|
+
if ((r.keeps ?? []).slice(0, i).some((k) => k.key === keep.key && k.to === keep.to)) refuse('E-RECIPE', `${where}.keeps[${i}]`, `${label(r)} carries “${keep.key}” to the same collectible twice.`, 'Keep one.');
|
|
1509
|
+
}
|
|
1510
|
+
if (!refusals.some((x) => x.where.startsWith(where))) {
|
|
1511
|
+
const { bytes, locks } = craftSize(r, m, { detail: true });
|
|
1512
|
+
if (bytes > MAX_TRANSACTION_BYTES && locks + TABLE_LOCKS > ACCOUNT_LOCKS)
|
|
1513
|
+
refuse('E-TX-SIZE', where, `Crafting ${label(r)} would take about ${bytes} bytes and ${locks} accounts, ${locks + TABLE_LOCKS} as a v0 transaction with a lookup table: more than the ${ACCOUNT_LOCKS} a transaction can lock, so no player could craft it.`, 'Take or give fewer collectibles, or split it into two recipes.');
|
|
1514
|
+
else if (bytes > MAX_TRANSACTION_BYTES)
|
|
1515
|
+
warn('W-TX-V0', where, `Crafting ${label(r)} takes about ${bytes} bytes, more than the ${MAX_TRANSACTION_BYTES} a legacy transaction holds: js/play sends it as a v0 transaction that loads its accounts from the project’s standing lookup table, which going live makes (one approval; going live is refused without it, E-NO-TABLE). A wallet or app that sends only legacy transactions can’t craft it.`, 'To keep it a legacy transaction, take or give fewer collectibles, or split it into two recipes.');
|
|
1516
|
+
else if (bytes > BUDGET)
|
|
1517
|
+
warn('W-TX-SIZE', where, `Crafting ${label(r)} takes about ${bytes} of ${MAX_TRANSACTION_BYTES} bytes: it fits only if the player’s wallet adds nothing of its own.`, 'Take or give fewer collectibles if players see it fail.');
|
|
1518
|
+
}
|
|
1519
|
+
// The tokens a craft moves: each pay line's (its currency, else the game's), and each prize of tokens' (prizeToken).
|
|
1520
|
+
const moved = [...ins.filter((l) => l.takes === 'pay').map((l) => m.tokens.get(l.currency ?? m.currencyKey)), ...outs.map((l) => prizeToken(l, m))];
|
|
1521
|
+
const hooks = moved.filter((t) => t?.powers?.transferHook);
|
|
1522
|
+
if (hooks.length) warn('W-TX-SIZE', where, `${label(r)} moves ${hooks[0].symbol}, whose transfer hook adds accounts of its own to every craft; the checker cannot count them from here.`, 'Try a craft on devnet before players do.');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ---- a set's way out: a recipe that lowers the lock of staked pieces lets them leave their farm. With a "pay"
|
|
1526
|
+
// line it waits while the project is paused or the platform suspends it, so when it is the only way out the pieces
|
|
1527
|
+
// stay locked for as long as that lasts: the one thing the platform's switch would hold that an unstake needs.
|
|
1528
|
+
const lockKeys = new Set(m.farms.map((f) => f.lockAttribute).filter(Boolean));
|
|
1529
|
+
const unbinds = m.recipes.map((r) => {
|
|
1530
|
+
const places = (r.in ?? []).flatMap((l) => (l.takes === 'burn' ? Array(l.count ?? 1).fill('burn') : l.takes === 'pay' ? [] : [l.takes]));
|
|
1531
|
+
const keys = new Set((r.raises ?? []).filter((x) => lockKeys.has(x.key) && places[x.place] === 'staked' && toBy(x.by) < 0).map((x) => x.key));
|
|
1532
|
+
return { r, keys, pays: (r.in ?? []).some((l) => l.takes === 'pay') };
|
|
1533
|
+
}).filter((u) => u.keys.size);
|
|
1534
|
+
for (const u of unbinds.filter((x) => x.pays)) {
|
|
1535
|
+
const only = [...u.keys].filter((k) => !unbinds.some((o) => !o.pays && o.keys.has(k)));
|
|
1536
|
+
if (only.length)
|
|
1537
|
+
warn('W-UNBIND-PAY', `economy.recipes.${u.r.key}`, `${label(u.r)} lets staked pieces out (it lowers “${only[0]}”) and takes a payment, and no other recipe lets them out for free. A recipe with a "pay" line can't be crafted while the project is paused or the platform suspends it, so for as long as that lasts the pieces stay locked in their farm: their lock holds the unstake.`, 'Take the "pay" line out of the way out (charge when binding instead), or add one that takes no payment.');
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ---- schedules (schedule.rs)
|
|
1541
|
+
for (const s of m.schedules) {
|
|
1542
|
+
const where = `economy.schedules.${s.key}`;
|
|
1543
|
+
if (fromNowSeconds(s.every, `${where}.every`) <= 0) refuse('E-SCHEDULE', `${where}.every`, `${label(s)} never comes round (every 0).`, 'Give it a time like "1d".');
|
|
1544
|
+
try {
|
|
1545
|
+
toTime(s.start, now);
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
refuse(err.code, `${where}.start`, err.message, err.fix);
|
|
1548
|
+
}
|
|
1549
|
+
const byShare = s.percent !== undefined;
|
|
1550
|
+
if (s.does !== 'issue' && byShare && s.amount !== undefined) refuse('E-SCHEDULE', where, `${label(s)} names both a percent and an amount.`, 'Keep one.');
|
|
1551
|
+
if (s.does === 'issue') {
|
|
1552
|
+
// Into a pool, or a farm's rewards vault ("to": "farm:<key>").
|
|
1553
|
+
const intoFarm = destination(s.to ?? '').kind === 'farm';
|
|
1554
|
+
const farm = intoFarm ? m.farm.get(destination(s.to).key) : null;
|
|
1555
|
+
if (intoFarm && !farm) refuse('E-REF', `${where}.to`, `There is no farm “${destination(s.to).key}”.`, 'Name one of "farms" by its key.');
|
|
1556
|
+
const p = intoFarm ? null : s.pool ? pool(s.pool, `${where}.pool`) : refuse('E-SCHEDULE', where, `${label(s)} mints but does not say into which pool or farm.`, 'Add "pool", or "to": "farm:<key>".');
|
|
1557
|
+
const tok = token(s.currency ?? p?.currency ?? farm?.rewardsToken, `${where}.currency`);
|
|
1558
|
+
if (farm && tok && farm.rewardsToken !== tok.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} mints ${tok.symbol} into ${label(farm)}, whose rewards are ${m.tokens.get(farm.rewardsToken)?.symbol ?? farm.rewardsToken}.`, 'Mint the farm’s rewards token.');
|
|
1559
|
+
if (tok && !tok.gameMints) refuse('E-MINT-NOT-OURS', where, `${label(s)} mints ${tok.symbol}, but the project cannot mint it: only a token the project makes itself can be minted, so every run of it would fail.`, 'Fill the pool with a share of sales or a move from another pool, or use a token with source "project".');
|
|
1560
|
+
if (p && tok && p.currency !== tok.key) refuse('E-POOL-MINT', `${where}.pool`, `${label(s)} mints ${tok.symbol} into “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`, `Mint into a pool of ${tok.symbol}.`);
|
|
1561
|
+
if (units(s.amount, tok, `${where}.amount`, `What ${label(s)} mints`) === 0n) refuse('E-SCHEDULE', `${where}.amount`, `${label(s)} mints 0.`, 'Give it an amount above 0.');
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
if (s.sealed) {
|
|
1565
|
+
// A sealed schedule pays from a vault of its own (`["vest", schedule]`), in `currency`: written once, never edited
|
|
1566
|
+
// or switched off, its last run pays whatever is left (schedule.rs).
|
|
1567
|
+
const tok = token(s.currency ?? m.currencyKey, `${where}.currency`);
|
|
1568
|
+
if (s.does !== 'pay') refuse('E-SCHEDULE', `${where}.does`, `${label(s)} is sealed, so it pays from a vault of its own: it can only "pay".`, 'Write "does": "pay", or leave "sealed" out.');
|
|
1569
|
+
if (s.from || s.pool) refuse('E-SCHEDULE', where, `${label(s)} is sealed: it pays from a vault of its own, not from a pool.`, 'Remove "from" and "pool"; fund it with "fund".');
|
|
1570
|
+
if (!(s.maxRuns > 0)) refuse('E-SCHEDULE', `${where}.maxRuns`, `${label(s)} is sealed and never ends: its last run is what pays out whatever is left (BadSchedule).`, 'Give it "maxRuns".');
|
|
1571
|
+
if (s.active === false) refuse('E-SCHEDULE', `${where}.active`, `${label(s)} is sealed and switched off: it could never be switched on (BadSchedule).`, 'Leave "active" out.');
|
|
1572
|
+
const d = destination(s.to ?? '');
|
|
1573
|
+
// A wallet that is a program's address (off the curve) nobody can sign for: a run would pay where nobody can
|
|
1574
|
+
// spend it, or pass it over (BadMove).
|
|
1575
|
+
if (d.kind === 'wallet' && isAddress(d.key) && !isOnCurve(addressBytes(d.key))) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays ${d.key}, the address of a program's account, not a wallet someone holds the key of: nobody could spend what it pays (BadMove).`, 'Pay the wallet of a person (its public key).');
|
|
1576
|
+
// Its last run within 10 years of going live.
|
|
1577
|
+
if (s.maxRuns > 0) {
|
|
1578
|
+
let first = now + seconds(s.every, `${where}.every`);
|
|
1579
|
+
try {
|
|
1580
|
+
if (s.start) first = Math.max(now, toTime(s.start, now));
|
|
1581
|
+
} catch {
|
|
1582
|
+
// A start that doesn't read is refused where the schedule's times are checked.
|
|
1583
|
+
}
|
|
1584
|
+
if (first + seconds(s.every, `${where}.every`) * (s.maxRuns - 1) - now > MAX_SCHEDULE_SECONDS) refuse('E-SCHEDULE', `${where}.maxRuns`, `${label(s)} is sealed and its last run would come more than 10 years after go-live (BadSchedule).`, 'Fewer runs, a shorter "every", or an earlier "start".');
|
|
1585
|
+
}
|
|
1586
|
+
if (s.maxRuns > MAX_SEALED_RUNS) refuse('E-SCHEDULE', `${where}.maxRuns`, `${label(s)} is sealed with ${s.maxRuns} runs; a sealed schedule runs at most ${MAX_SEALED_RUNS} times (BadSchedule).`, 'Fewer runs, each larger.');
|
|
1587
|
+
// A wallet, the treasury or a burn: a stream into a pool or an account would be only as safe as where it lands (the
|
|
1588
|
+
// authority moves a pool; an account's holder may close it).
|
|
1589
|
+
if (!['wallet', 'treasury', 'burn'].includes(d.kind) || !s.to) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} is sealed and pays “${s.to ?? 'nowhere'}”: a sealed schedule pays a wallet, the treasury or a burn, never a pool, a pot, a farm or an account (BadMove).`, 'Write "to": "wallet:<address>"; for a stream into a pool, a pot or a farm, leave "sealed" out.');
|
|
1590
|
+
if (tok?.wsol && d.kind === 'burn') refuse('E-WSOL-BURN', `${where}.to`, `${label(s)} burns SOL, and wrapped SOL cannot be burned.`, 'Pay it somewhere instead.');
|
|
1591
|
+
startsFrozen(tok, `${where}.currency`, `${label(s)} (sealed)`, 'E-SCHEDULE', 'BadSchedule');
|
|
1592
|
+
if (tok?.powers?.permanentDelegate || tok?.powers?.nonTransferable || tok?.powers?.confidentialTransfers)
|
|
1593
|
+
refuse('E-SCHEDULE', `${where}.currency`, `${label(s)} is sealed in ${tok.symbol}, whose mint ${tok.powers.permanentDelegate ? 'has a permanent delegate, who could take the tokens out of the vault' : 'can’t move tokens out of a vault by a plain transfer'} (BadSchedule).`, 'Seal it in another token.');
|
|
1594
|
+
if (byShare && !(percent(s.percent, `${where}.percent`) > 0)) refuse('E-SCHEDULE', `${where}.percent`, `${label(s)} pays 0%.`, 'Give it a percent above 0.');
|
|
1595
|
+
else if (!byShare && units(s.amount, tok, `${where}.amount`, `What ${label(s)} pays`) === 0n) refuse('E-SCHEDULE', `${where}.amount`, `${label(s)} pays nothing.`, 'Give it an amount or a percent above 0.');
|
|
1596
|
+
if (s.fund !== undefined) units(s.fund, tok, `${where}.fund`, `${label(s)}’s fund`);
|
|
1597
|
+
if (s.fundFrom === 'mint' && tok && !tok.gameMints) refuse('E-MINT-NOT-OURS', `${where}.fundFrom`, `${label(s)} is funded by minting ${tok.symbol}, which the project cannot mint.`, 'Fund it from the owner\'s wallet or a pool.');
|
|
1598
|
+
if (s.fundFrom?.startsWith('pool:')) pool(s.fundFrom.slice(5), `${where}.fundFrom`);
|
|
1599
|
+
warn('W-SCHEDULE', where, `${label(s)} is sealed: once written it never closes, and nobody can edit or stop it. Check its terms before it goes live.`);
|
|
1600
|
+
if (!Number(s.fund ?? 0)) warn('W-SCHEDULE', `${where}.fund`, `${label(s)} starts empty: its runs pay nothing until someone sends tokens to its vault.`, 'Give it "fund".');
|
|
1601
|
+
if (d.kind === 'treasury') warn('W-SCHEDULE', `${where}.to`, `${label(s)} pays the treasury: the owner can change which wallet that is, so it is the owner's promise to themselves.`);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
const sourceKey = s.does === 'burn' ? s.pool : s.from;
|
|
1605
|
+
const source = sourceKey ? pool(sourceKey, `${where}.${s.does === 'burn' ? 'pool' : 'from'}`) : refuse('E-SCHEDULE', where, `${label(s)} ${s.does === 'burn' ? 'burns' : 'moves'} money but does not say from which pool.`, `Add "${s.does === 'burn' ? 'pool' : 'from'}".`);
|
|
1606
|
+
const srcToken = source && m.tokens.get(source.currency);
|
|
1607
|
+
if (byShare) {
|
|
1608
|
+
if (!(percent(s.percent, `${where}.percent`) > 0)) refuse('E-SCHEDULE', `${where}.percent`, `${label(s)} ${s.does === 'burn' ? 'burns' : 'moves'} 0%.`, 'Give it a percent above 0.');
|
|
1609
|
+
} else if (units(s.amount, srcToken, `${where}.amount`, `What ${label(s)} moves`) === 0n) refuse('E-SCHEDULE', `${where}.amount`, `${label(s)} moves nothing.`, 'Give it an amount or a percent above 0.');
|
|
1610
|
+
if (s.does === 'burn' && srcToken?.wsol) refuse('E-WSOL-BURN', `${where}.pool`, `${label(s)} burns SOL from “${source.key}”, and wrapped SOL cannot be burned.`, 'Move it to another pool instead.');
|
|
1611
|
+
if (s.does === 'move') {
|
|
1612
|
+
const to = s.to ? pool(s.to, `${where}.to`) : refuse('E-SCHEDULE', where, `${label(s)} moves money but does not say to which pool.`, 'Add "to".');
|
|
1613
|
+
if (source && to && source.key === to.key) refuse('E-SCHEDULE', where, `${label(s)} moves money from “${source.key}” into itself.`, 'Name two different pools.');
|
|
1614
|
+
if (source && to && source.currency !== to.currency) refuse('E-POOL-MINT', where, `${label(s)} moves from “${source.key}” to “${to.key}”, which hold different tokens.`, 'Name two pools of the same token.');
|
|
1615
|
+
}
|
|
1616
|
+
if (s.does === 'pay') {
|
|
1617
|
+
const d = destination(s.to ?? '');
|
|
1618
|
+
if (d.kind === 'lottery') m.lottery.has(d.key) || refuse('E-REF', `${where}.to`, `There is no lottery “${d.key}”.`, 'Name one of "lotteries" by its key.');
|
|
1619
|
+
else if (d.kind === 'farm') {
|
|
1620
|
+
const f = m.farm.get(d.key);
|
|
1621
|
+
if (!f) refuse('E-REF', `${where}.to`, `There is no farm “${d.key}”.`, 'Name one of "farms" by its key.');
|
|
1622
|
+
else if (srcToken && f.rewardsToken !== srcToken.key) refuse('E-POOL-MINT', `${where}.to`, `${label(s)} pays ${srcToken.symbol} into ${label(f)}, whose rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken}.`, 'Pay it from a pool of the farm’s rewards token.');
|
|
1623
|
+
} else if (!['account', 'holder', 'wallet'].includes(d.kind) || !isAddress(d.key)) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} pays “${s.to ?? 'nowhere'}”: a pay schedule pays a wallet, a token account, a collectible’s holder, a lottery’s pot or a farm’s rewards.`, 'Write "to": "wallet:<address>", "account:<token account address>", "holder:<asset address>", "lottery:<key>" or "farm:<key>".');
|
|
1624
|
+
else if (d.kind === 'account' && gameVault(d.key, m.poolToken(sourceKey))) refuse('E-SCHEDULE', `${where}.to`, `${label(s)} pays the game’s own vault, where open orders, prizes set aside and bids wait: the program refuses it.`, 'Pay a pool or a wallet instead.');
|
|
1625
|
+
warn('W-SCHEDULE-PAY', where, `${label(s)} pays out of “${sourceKey}” on a timer, but the pool stays the game’s: the authority can still spend it, and the owner can rewrite or stop the schedule. It is a promise the project keeps, not a vesting contract the recipient holds.`, 'Make it "sealed" (with "fund" and "maxRuns") for a vesting nobody can take back.');
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// ---- lotteries (lottery.rs)
|
|
1630
|
+
for (const l of m.lotteries) {
|
|
1631
|
+
const where = `economy.lotteries.${l.key}`;
|
|
1632
|
+
const potToken = token(l.currency, `${where}.currency`);
|
|
1633
|
+
const ticket = l.ticketCurrency ? token(l.ticketCurrency, `${where}.ticketCurrency`) : potToken;
|
|
1634
|
+
confidential(potToken, `${where}.currency`, label(l), 'E-LOTTERY');
|
|
1635
|
+
if (ticket !== potToken) confidential(ticket, `${where}.ticketCurrency`, label(l), 'E-LOTTERY');
|
|
1636
|
+
startsFrozen(potToken, `${where}.currency`, label(l), 'E-LOTTERY', 'BadLottery');
|
|
1637
|
+
if (ticket !== potToken) startsFrozen(ticket, `${where}.ticketCurrency`, `${label(l)}’s ticket`, 'E-LOTTERY', 'BadLottery');
|
|
1638
|
+
if (l.ticketCurrency && ticket?.wsol) refuse('E-LOTTERY', `${where}.ticketCurrency`, 'Wrapped SOL cannot be burned as a ticket token.', 'Use SOL as the lottery currency, or use a burnable non-native ticket token.');
|
|
1639
|
+
if (l.ticketNft) {
|
|
1640
|
+
// A ticket paid with an NFT: no price, no split, no ticket token (lottery.rs BadLottery).
|
|
1641
|
+
if (l.price !== undefined && Number(l.price) > 0) refuse('E-LOTTERY', `${where}.price`, `${label(l)}’s tickets cost an NFT, and a price too: a ticket costs one or the other (BadLottery).`, 'Leave "price" out.');
|
|
1642
|
+
if (l.ticketCurrency) refuse('E-LOTTERY', `${where}.ticketCurrency`, `${label(l)}’s tickets cost an NFT: there is no ticket token (BadLottery).`, 'Leave "ticketCurrency" out.');
|
|
1643
|
+
if (lotterySplit(l).length) refuse('E-LOTTERY', `${where}.split`, `${label(l)}’s tickets cost an NFT: there is no ticket money to split (BadLottery).`, 'Remove the split and the cut.');
|
|
1644
|
+
rule(l.ticketNft.rule, `${where}.ticketNft.rule`, `${label(l)}’s ticket`);
|
|
1645
|
+
thenCheck(l.ticketNft.then, `${where}.ticketNft.then`, label(l));
|
|
1646
|
+
if (!l.noLoss) warn('W-NFT-PRICE', `${where}.ticketNft`, `${label(l)}’s tickets cost one NFT each, ${!l.ticketNft.then || l.ticketNft.then === 'burn' ? 'burned' : l.ticketNft.then.stash ? `put in “${l.ticketNft.then.stash}”` : 'given on'} once the round is drawn (the project holds it until then, and hands it back if the draw is cancelled). The pot is what the owner seeds and what its places' prizes give.`, Number(l.seed ?? 0) > 0 || (l.places ?? []).some((p) => typeof p === 'object' && p.prize) ? undefined : 'Seed the pot ("seed"), or give its places prizes.');
|
|
1647
|
+
else if (l.ticketNft.then) warn('W-LOTTERY', `${where}.ticketNft.then`, `${label(l)} gives every ticket back, so each NFT paid goes back to its buyer after the draw and "then" is never used.`, 'Leave "then" out.');
|
|
1648
|
+
} else if (l.price === undefined || units(l.price, ticket, `${where}.price`, `${label(l)}’s ticket`) === 0n)
|
|
1649
|
+
warn('W-LOTTERY', `${where}.price`, `${label(l)}’s tickets are free: a buyer pays only the player fee (${fromUnits(FEES.player, 9)} SOL)${l.perWallet ? '' : ', and with no "perWallet" one wallet can hold most of the tickets'}. Its pot is only what is paid into it.`, 'Give a ticket a "price" if it should cost something, or set "perWallet".');
|
|
1650
|
+
units(l.seed, potToken, `${where}.seed`, `${label(l)}’s seed`);
|
|
1651
|
+
if (l.roundRent !== undefined) units(l.roundRent, { decimals: 9, symbol: 'SOL' }, `${where}.roundRent`, `${label(l)}’s round rent`);
|
|
1652
|
+
if (seconds(l.every, `${where}.every`) <= 0 && !l.firstDraw) warn('W-LOTTERY', `${where}.every`, `${label(l)} draws once (no "every"), and the draw is due at go-live.`, 'Give it a "firstDraw" (like "publish+1d"), or an "every" to draw again and again.');
|
|
1653
|
+
// A round whose draw is more than five years off sells nothing (lottery.rs).
|
|
1654
|
+
if (seconds(l.every, `${where}.every`) > MAX_CONTEST_SECONDS) refuse('E-LOTTERY', `${where}.every`, `${label(l)} draws every ${l.every}: a draw more than 5 years off sells no ticket (SalesClosed).`, 'Draw within 5 years.');
|
|
1655
|
+
try {
|
|
1656
|
+
if (l.firstDraw && toTime(l.firstDraw, now) - now > MAX_CONTEST_SECONDS) refuse('E-LOTTERY', `${where}.firstDraw`, `${label(l)} first draws more than 5 years after go-live: no ticket sells until the draw is within 5 years (SalesClosed).`, 'Draw sooner.');
|
|
1657
|
+
toTime(l.firstDraw, now);
|
|
1658
|
+
} catch (err) {
|
|
1659
|
+
refuse(err.code, `${where}.firstDraw`, err.message, err.fix);
|
|
1660
|
+
}
|
|
1661
|
+
// A place takes a share of the pot, a roll of a box for its winner, or both (lottery.rs Place).
|
|
1662
|
+
const placed = (l.places ?? [100]).map(placeOf);
|
|
1663
|
+
tooMany(placed.length, LIMITS.lotteryRuns, 'E-LOTTERY', `${where}.places`, `${label(l)} has ${placed.length} runs of places`, 'BadLottery', 'Put alike places into one run with "times".');
|
|
1664
|
+
const places = placed.map((p, i) => percent(p.percent, `${where}.places[${i}]${typeof (l.places ?? [])[i] === 'object' ? '.percent' : ''}`));
|
|
1665
|
+
for (const [i, p] of placed.entries()) {
|
|
1666
|
+
const raw = (l.places ?? [])[i];
|
|
1667
|
+
if (!places[i] && !p.prize) refuse('E-LOTTERY', `${where}.places[${i}]`, `${label(l)}’s place ${i + 1} wins nothing: no share of the pot and no prize (BadLottery).`, 'Give it a "percent" above 0, a "prize", or remove it.');
|
|
1668
|
+
if (typeof raw === 'object' && raw?.prize) prizeCheck(raw.prize, `${where}.places[${i}].prize`, `${label(l)}’s place ${i + 1}`, 'E-LOTTERY');
|
|
1669
|
+
// A run holds places alike: a named NFT or a roll is one place's.
|
|
1670
|
+
if (p.times > 1 && ((p.prize?.gives === 'nft') || p.prize?.gives === 'roll'))
|
|
1671
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} repeats ${p.times} times a prize one place gives (a named NFT, a roll) (BadLottery).`, 'Give it to one place, or make it a prize places can share alike (tokens, an item, an NFT drawn from the lottery’s stash).');
|
|
1672
|
+
// A run of places given one by one (anything but tokens: a collectible, a compressed NFT, a raffled NFT) holds at
|
|
1673
|
+
// most MAX_RUN_PLACES places: a pull walks the run from its first place (BadLottery).
|
|
1674
|
+
if (p.prize && p.prize.gives !== 'tokens' && p.times > MAX_RUN_PLACES)
|
|
1675
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} gives ${prizeWords(p.prize, m)} to ${p.times.toLocaleString('en-US')} places in a row: a run of places given one by one holds at most ${MAX_RUN_PLACES.toLocaleString('en-US')}, since each pull walks it from its first place (BadLottery).`, `Split it into runs of ${MAX_RUN_PLACES.toLocaleString('en-US')} places or fewer, one after another.`);
|
|
1676
|
+
// A run's token prize in all, each place's amount times its places, fits a u64 (BadLottery).
|
|
1677
|
+
const t = p.prize?.amount !== undefined ? prizeToken(p.prize, m) : null;
|
|
1678
|
+
let each = 0n;
|
|
1679
|
+
try {
|
|
1680
|
+
each = t ? toUnits(p.prize.amount, t.decimals ?? 0) : 0n;
|
|
1681
|
+
} catch {
|
|
1682
|
+
// An amount that doesn't read is refused where the prize is checked.
|
|
1683
|
+
}
|
|
1684
|
+
if (each * BigInt(p.times ?? 1) > U64_MAX)
|
|
1685
|
+
refuse('E-LOTTERY', `${where}.places[${i}].times`, `${label(l)}’s place ${i + 1} gives ${p.prize.amount} ${p.times} times: more tokens in all than the program can count (BadLottery).`, 'Fewer places in the run, or a smaller prize.');
|
|
1686
|
+
}
|
|
1687
|
+
// A raffle of many NFTs: its places draw from the lottery's own stash, which holds at least as many; it draws once,
|
|
1688
|
+
// and never also names NFTs of it (lottery.rs).
|
|
1689
|
+
const raffled = placed.filter((p) => p.prize?.gives === 'raffled').reduce((n, p) => n + p.times, 0);
|
|
1690
|
+
if (raffled && l.every) refuse('E-LOTTERY', `${where}.every`, `${label(l)} draws NFTs from its own stash, so it draws once (BadLottery).`, 'Leave "every" out.');
|
|
1691
|
+
if (raffled && placed.some((p) => p.prize?.held)) refuse('E-LOTTERY', `${where}.places`, `${label(l)} both names NFTs of its own stash and draws from it: a draw could take a named one (BadLottery).`, 'Raffle them all at random, or name each one.');
|
|
1692
|
+
if (raffled > (l.nfts ?? []).length) refuse('E-LOTTERY', `${where}.nfts`, `${label(l)} draws ${raffled} NFTs from its own stash, which holds ${(l.nfts ?? []).length} (NotInStash).`, 'List at least as many in "nfts".');
|
|
1693
|
+
// A raffle's NFT: one place each, and a lottery that holds one draws once (lottery.rs).
|
|
1694
|
+
const held = placed.map((p) => (p.prize?.held ? p.prize.nft : null)).filter(Boolean);
|
|
1695
|
+
if (held.length && l.every) refuse('E-LOTTERY', `${where}.every`, `${label(l)} holds an NFT as a prize, so it draws once: a next round would sell a prize already won (BadLottery).`, 'Leave "every" out.');
|
|
1696
|
+
if (new Set(held).size < held.length) refuse('E-LOTTERY', `${where}.places`, `${label(l)} gives the same NFT on two places: the second could never be given (BadLottery).`, 'Name each NFT once.');
|
|
1697
|
+
// One pull takes every NFT its places win out of the stash, in place order (lottery.rs `assign_fits`).
|
|
1698
|
+
const rows = (l.nfts ?? []).length + held.length;
|
|
1699
|
+
const draw = 6_000 + 250 * Math.ceil(rows / 4_096);
|
|
1700
|
+
if (raffled * draw + held.length * 15 * rows > 1_300_000) refuse('E-LOTTERY', `${where}.places`, `${label(l)} draws ${raffled} and names ${held.length} NFTs of its own stash of ${rows}: one pull takes them all out, and that would pass what one transaction can compute (BadLottery).`, 'Fewer raffled places (about 200 at most in a small stash), or a smaller stash.');
|
|
1701
|
+
if (held.length) warn('W-RAFFLE', `${where}.places`, `${label(l)} holds ${held.length === 1 ? 'its NFT' : 'its NFTs'} in its own stash: nobody, the owner included, can take ${held.length === 1 ? 'it' : 'them'} out while a place names ${held.length === 1 ? 'it' : 'them'}, and the winner pulls ${held.length === 1 ? 'it' : 'them'} after the draw, in any order with the money. If the NFT's collection lets someone freeze or move it (a permanent freeze, transfer or burn delegate), they could stop the prize: anyone may then make it a hold for its winner, so the rest of the round pays out.`);
|
|
1702
|
+
if (l.minSold && l.perWallet && !l.every && l.minSold > l.perWallet * 1000) warn('W-LOTTERY', `${where}.minSold`, `${label(l)} needs ${l.minSold} tickets sold, and each wallet may hold ${l.perWallet}: it takes at least ${Math.ceil(l.minSold / l.perWallet)} wallets.`);
|
|
1703
|
+
const total = places.reduce((a, b, i) => a + b * placed[i].times, 0);
|
|
1704
|
+
if (total > 10_000) refuse('E-LOTTERY', `${where}.places`, `${label(l)}’s places add up to ${total / 100}% of the pot, more than all of it.`, 'Make them add up to 100% or less.');
|
|
1705
|
+
else if (total < 10_000) warn('W-LOTTERY', `${where}.places`, `${label(l)}’s places add up to ${total / 100}%: the other ${(10_000 - total) / 100}% of each pot stays in it for the next round, as do places nobody won.`);
|
|
1706
|
+
const ticketSplit = lotterySplit(l);
|
|
1707
|
+
if (ticketSplit.length && l.ticketCurrency) refuse('E-LOTTERY', `${where}.split`, `${label(l)}’s tickets are burned, so there is no ticket money to split.`, 'Remove the split and the cut.');
|
|
1708
|
+
split(ticketSplit, potToken, `${where}.split`, { what: `${label(l)}’s ticket money`, chosen: true });
|
|
1709
|
+
if (ticket?.wsol && l.ticketCurrency) refuse('E-WSOL-BURN', `${where}.ticketCurrency`, `${label(l)}’s tickets are burned, and wrapped SOL cannot be burned.`, 'Sell tickets into the pot instead (no ticketCurrency).');
|
|
1710
|
+
// No loss (lottery.rs `returns`): every ticket goes back whole after the draw, so nothing of it can be split or burned.
|
|
1711
|
+
if (l.noLoss) {
|
|
1712
|
+
if (ticketSplit.length) refuse('E-LOTTERY', `${where}.split`, `${label(l)} gives every ticket back to its buyer after the draw, so no part of the ticket money can go anywhere else (BadLottery).`, 'Remove the split and the cut, or make it a lottery that keeps its tickets (no "noLoss").');
|
|
1713
|
+
if (l.ticketCurrency) refuse('E-LOTTERY', `${where}.ticketCurrency`, `${label(l)} gives every ticket back, and tickets in "ticketCurrency" are burned: a ticket can’t be both (BadLottery).`, 'Leave "ticketCurrency" out: tickets are paid in the pot’s currency and come back.');
|
|
1714
|
+
if (!lotteryPotFilled(l)) warn('W-LOTTERY', where, `${label(l)} gives every ticket back, so its pot holds only what is paid into it, and nothing is: its winners would win nothing.`, `Seed the pot ("seed"), send it a share of a sale or payment ("lottery:${l.key}" in a split, or a schedule that pays "to": "lottery:${l.key}"), or give its places prizes.`);
|
|
1715
|
+
else warn('W-LOTTERY', where, `${label(l)} loses nobody their tickets: every ticket goes back to its buyer once the round is drawn (anyone can finish each purchase), and the winners take only the pot, what is paid into it (the seed, a sponsor, a share of other sales). Ticket money waits in the lottery’s own vault until then. Tickets are not weighted by how long they were held: someone may buy most of a round’s tickets right before the draw and risk only the fees.`, 'A "perWallet" limit bounds one wallet, not many; keep the pot small next to what a late buyer would put in, or gate it (a stake, a list).');
|
|
1716
|
+
}
|
|
1717
|
+
rule(l.gate, `${where}.gate`, label(l));
|
|
1718
|
+
chance = true;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// ---- farms (farm.rs)
|
|
1722
|
+
for (const f of m.farms) {
|
|
1723
|
+
const where = `economy.farms.${f.key}`;
|
|
1724
|
+
const ofCollectibles = f.stakes === 'collectibles';
|
|
1725
|
+
// A farm that shares what arrives releases it to the seats over its release span (its longest lock, a day at
|
|
1726
|
+
// least), by weight as it is released: nobody takes at once what landed before them. A seat alone still
|
|
1727
|
+
// earns all that is released while it is alone, however small it is.
|
|
1728
|
+
// Its rewards land in a vault of its own (`["rewards", farm]`): `rewards` names their token, never a pool.
|
|
1729
|
+
if (f.rewards && !m.tokens.has(f.rewards)) {
|
|
1730
|
+
if (m.pool.has(f.rewards)) refuse('E-FARM', `${where}.rewards`, `${label(f)} names the pool “${f.rewards}” for its rewards: a farm’s rewards land in a vault of its own now, and "rewards" names their token.`, `Write "rewards": "${m.pool.get(f.rewards).currency}", and send the farm what it shares with "farm:${f.key}" (in a split, or a schedule that pays it) or "seed".`);
|
|
1731
|
+
else token(f.rewards, `${where}.rewards`);
|
|
1732
|
+
}
|
|
1733
|
+
const rewardsToken = m.tokens.get(f.rewardsToken);
|
|
1734
|
+
const fills = farmFillers(m).filter((x) => x.farm === f.key);
|
|
1735
|
+
if (f.seed !== undefined) units(f.seed, rewardsToken, `${where}.seed`, `The seed of ${label(f)}`);
|
|
1736
|
+
if (!f.rate?.minted && Number(f.rate?.perDay ?? 1) > 0 && !fills.length)
|
|
1737
|
+
warn('W-FARM-DRY', `${where}`, `${label(f)} ${f.rate ? 'pays its rate from what its rewards vault holds free' : 'shares what lands in its rewards vault'}, and nothing in this spec sends it any: its seats earn nothing until someone does.`, `Seed it ("seed"), send it a share of a sale ("farm:${f.key}" in a split), or pay it with a schedule ("to": "farm:${f.key}").`);
|
|
1738
|
+
// A farm that shares what arrives releases it to the seats over its release span (its longest lock, a day at
|
|
1739
|
+
// least), by weight as it is released: nobody takes at once what landed before them. A seat alone still
|
|
1740
|
+
// earns all that is released while it is alone, however small it is.
|
|
1741
|
+
if (!f.rate && fills.length) warn('W-FARM-FIRST', `${where}.rewards`, `${label(f)} shares what lands in its rewards vault (${fills.map((x) => x.by).join(', ')}), released over ${releaseWords(f)} as seats stay: whoever stakes first is credited all that is released while they are the only one in, even with 1 unit.`, 'Have the owner hold a small seat from the start, or tell stakers early ones share the first rewards among few.');
|
|
1742
|
+
// A rewards mint that keeps a fee on transfers: what lands in the vault and what each seat is paid arrive less it.
|
|
1743
|
+
const feePercent = Number(rewardsToken?.powers?.transferFeePercent ?? 0);
|
|
1744
|
+
if (toBps(feePercent) > 0 && !f.rate?.minted)
|
|
1745
|
+
warn('W-FARM-FEE', `${where}.rewards`, `${label(f)}’s rewards keep ${feePercent}% of every transfer: what lands in its vault arrives less that, and so does what each seat is paid.`, 'Use a rewards token without a fee, or say so to stakers.');
|
|
1746
|
+
if (!ofCollectibles) {
|
|
1747
|
+
const tok = token(f.stakes, `${where}.stakes`);
|
|
1748
|
+
if (tok?.wsol) warn('W-FARM', `${where}.stakes`, `${label(f)} stakes SOL: players stake wrapped SOL, which their wallet makes for them.`);
|
|
1749
|
+
if (f.item) refuse('E-FARM', `${where}.item`, `${label(f)} stakes a token, so it takes no item.`, 'Remove "item", or make it a farm of collectibles ("stakes": "collectibles").');
|
|
1750
|
+
if (f.custody === 'watch') refuse('E-FARM', `${where}.custody`, `${label(f)} stakes a token: its boosters can’t be left free and watched, or one sold could keep boosting.`, 'Use "custody": "escrow" or "freeze", or make it a farm of collectibles.');
|
|
1751
|
+
}
|
|
1752
|
+
// Early exits (farm.rs): a fee leaves to a burn, a pool of its token or the farm's own vault, checked once at open.
|
|
1753
|
+
const exits = (f.tiers ?? []).some((t) => Number(t.exitFee ?? 0) > 0);
|
|
1754
|
+
const sinkOk = (to, at, tokenKey, what) => {
|
|
1755
|
+
if (to === 'rewards') {
|
|
1756
|
+
if (tokenKey !== f.rewardsToken) refuse('E-FARM', at, `${label(f)} sends ${what} into its own rewards vault, but it stakes ${m.tokens.get(tokenKey)?.symbol ?? tokenKey} and its rewards are ${m.tokens.get(f.rewardsToken)?.symbol ?? f.rewardsToken} (WrongMint).`, 'Send it to a burn or a pool, or make its rewards its staked token.');
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (to === 'burn') {
|
|
1760
|
+
if (m.tokens.get(tokenKey)?.wsol) refuse('E-WSOL-BURN', at, `${label(f)} burns ${what} in SOL, and wrapped SOL cannot be burned: every exit or harvest that owes it would fail.`, 'Send it to a pool instead.');
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const p = pool(String(to).slice(5), at);
|
|
1764
|
+
if (p && tokenKey && p.currency !== tokenKey) refuse('E-POOL-MINT', at, `${label(f)} sends ${what} to “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol ?? p.currency}, not ${m.tokens.get(tokenKey)?.symbol ?? tokenKey} (WrongMint).`, `Send it to a pool of ${m.tokens.get(tokenKey)?.symbol ?? tokenKey}.`);
|
|
1765
|
+
};
|
|
1766
|
+
if (ofCollectibles && exits) refuse('E-FARM', `${where}.tiers`, `${label(f)} stakes collectibles: there is no stake to take an exit fee from (BadFarm). A collectible leaves early by a recipe that lowers its lock.`, 'Remove "exitFee", and give the farm a "lockAttribute" and a recipe that lowers it.');
|
|
1767
|
+
if (!ofCollectibles && exits && !f.exitFeeTo) refuse('E-FARM', `${where}.exitFeeTo`, `${label(f)} has an early-exit fee but does not say where it goes (BadFarm).`, 'Add "exitFeeTo": "burn", "rewards" or "pool:<key>".');
|
|
1768
|
+
if (!ofCollectibles && f.exitFeeTo) sinkOk(f.exitFeeTo, `${where}.exitFeeTo`, f.stakes, 'its exit fees');
|
|
1769
|
+
(f.tiers ?? []).forEach((t, i) => percent(t.exitFee, `${where}.tiers[${i}].exitFee`));
|
|
1770
|
+
if (!ofCollectibles && exits) warn('W-FARM', `${where}.tiers`, `${label(f)}’s tiers with an early exit may leave before the lock for the fee, keeping the rewards they were credited (a tier's extra weight can be bought for its exit fee): pick the fee with that in mind.`);
|
|
1771
|
+
if (f.exitFeeTo === 'rewards') warn('W-FARM', `${where}.exitFeeTo`, `${label(f)} sends early-exit fees into its own rewards vault: the stakers who stay share them.`);
|
|
1772
|
+
if (ofCollectibles && f.item) {
|
|
1773
|
+
const it = item(f.item, `${where}.item`);
|
|
1774
|
+
if (it && it.kind === 'normal') refuse('E-FARM', `${where}.item`, `${label(f)} takes ${label(it)}, a normal item: only collectibles can be staked.`, `Make ${label(it)} a collectible.`);
|
|
1775
|
+
if (it?.soulbound && (f.custody ?? 'escrow') === 'escrow') refuse('E-FARM', `${where}.custody`, `${label(f)} holds collectibles in escrow, and ${label(it)} is soulbound: it can’t move to the game.`, 'Use "custody": "freeze", or a collectible that is not soulbound.');
|
|
1776
|
+
}
|
|
1777
|
+
if (f.custody === 'watch' && listOf(f.growth).some((g) => g.streak)) refuse('E-FARM', `${where}.growth`, `${label(f)} watches NFTs left free in their wallets and counts a streak: one leaves by being moved, with nothing to set its streak back to 0, so the program refuses it (BadFarm).`, 'Use "custody": "escrow" or "freeze" for a streak, or drop "streak".');
|
|
1778
|
+
if (f.custody === 'watch') warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} watches NFTs left free in their wallets: one sold while staked stops earning for its staker (what its seat was owed ${f.rate ? (f.rate.minted ? 'is never minted' : 'stays free in its vault') + ', never to the other seats' : 'goes back to its vault, released again with the rest'}), and its new holder can take the seat over, paying the last staker back the seat’s rent.`);
|
|
1779
|
+
else if (f.custody === 'freeze') warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} locks NFTs in their wallets (a Core freeze, Token Metadata’s lock, a compressed NFT’s freeze), with the game the only key that unlocks them. For ordinary Core assets, issuer metadata or plugin growth can make unfreezing fail; a soulbound asset needs no extra freeze. This also applies to token-seat boosters.`, 'Use escrow for transferable collectibles, or retain freeze only with a trusted issuer and controlled metadata changes.');
|
|
1780
|
+
else if (ofCollectibles || (f.boosts ?? []).length) warn('W-FARM-CUSTODY', `${where}.custody`, `${label(f)} escrows collectibles. This avoids Core’s unfreeze operation, but issuer-controlled transfer rules can still block return. Soulbound collectibles require explicit freeze custody.`);
|
|
1781
|
+
const tiers = f.tiers ?? [{ lock: 0, multiplier: 1 }];
|
|
1782
|
+
if (tiers.length > LIMITS.tiers) refuse('E-FARM', `${where}.tiers`, `${label(f)} has ${tiers.length} lock tiers; a farm has at most ${LIMITS.tiers}.`, 'Merge tiers.');
|
|
1783
|
+
tooMany((f.boosts ?? []).length, LIMITS.farmRules, 'E-FARM', `${where}.boosts`, `${label(f)} has ${(f.boosts ?? []).length} boosts`, 'BadFarm', 'Keep the boosts that matter.');
|
|
1784
|
+
tooMany(listOf(f.gate).length, LIMITS.farmRules, 'E-FARM', `${where}.gate`, `${label(f)}’s gate has ${listOf(f.gate).length} rules`, 'BadFarm', 'Keep the rules that matter.');
|
|
1785
|
+
tooMany(listOf(f.growth).length, LIMITS.farmRules, 'E-FARM', `${where}.growth`, `${label(f)} has ${listOf(f.growth).length} growths`, 'BadFarm', 'Keep the numbers that matter.');
|
|
1786
|
+
for (const [i, g] of listOf(f.growth).entries()) tooMany((g.levels ?? []).length, LIMITS.growthSteps, 'E-FARM', `${where}.growth[${i}].levels`, `${label(f)}’s growth of “${g.attribute ?? 'xp'}” has ${(g.levels ?? []).length} levels`, 'BadFarm', 'Use fewer levels.');
|
|
1787
|
+
for (const [i, t] of tiers.entries()) {
|
|
1788
|
+
fourByteSeconds(t.lock, `${where}.tiers[${i}].lock`);
|
|
1789
|
+
// A prize given on leaving needs a lock, or it would be given again and again (BadFarm).
|
|
1790
|
+
if (t.prize && !(toSeconds(t.lock ?? 0) > 0)) refuse('E-FARM', `${where}.tiers[${i}].lock`, `${label(f)}’s tier ${i} gives a prize with no lock: stakes could come and go, and take it again and again (BadFarm).`, 'Give the tier a "lock" above 0.');
|
|
1791
|
+
if (t.earnsFor !== undefined) fourByteSeconds(t.earnsFor, `${where}.tiers[${i}].earnsFor`);
|
|
1792
|
+
const bps = Math.round((t.multiplier ?? 1) * 10_000);
|
|
1793
|
+
if (bps <= 0 || bps > LIMITS.maxTierBps) refuse('E-FARM', `${where}.tiers[${i}].multiplier`, `${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most x11.`, 'Use a multiplier above 0, up to 11.');
|
|
1794
|
+
else if (bps < 10_000) warn('W-FARM', `${where}.tiers[${i}].multiplier`, `${label(f)}’s tier ${i} weighs x${t.multiplier}, less than what it stakes: a flexible tier that earns less.`);
|
|
1795
|
+
}
|
|
1796
|
+
for (const [i, b] of (f.boosts ?? []).entries()) {
|
|
1797
|
+
rule(b.when, `${where}.boosts[${i}].when`, `${label(f)}’s boost`);
|
|
1798
|
+
effect(b.add, `${where}.boosts[${i}].add`, `${label(f)}’s boost`);
|
|
1799
|
+
}
|
|
1800
|
+
boostPercent(f.maxBoostPercent, `${where}.maxBoostPercent`);
|
|
1801
|
+
if ((f.boosts ?? []).length && !(f.maxBoostPercent > 0)) warn('W-FARM', `${where}.maxBoostPercent`, `${label(f)} has boosts but no "maxBoostPercent", so they add nothing (the cap is 0).`, 'Set "maxBoostPercent", for example 100 (at most double).');
|
|
1802
|
+
// A fixed rate (`rate`), growth (`growth`) and missions (tiers' `prize`).
|
|
1803
|
+
const growths = listOf(f.growth);
|
|
1804
|
+
const prized = tiers.some((t) => t.prize);
|
|
1805
|
+
const rolls = tiers.some((t) => t.roll);
|
|
1806
|
+
if (f.rate) {
|
|
1807
|
+
const perDay = units(f.rate.perDay, rewardsToken, `${where}.rate.perDay`, `${label(f)}’s rate`);
|
|
1808
|
+
if (!(Number(f.rate.perDay) > 0)) {
|
|
1809
|
+
if (!growths.length && !prized && !rolls) warn('W-FARM', `${where}.rate.perDay`, `${label(f)}’s fixed rate pays nothing a day, and it grows nothing, gives no prize and rolls no mission: staking there only counts (a stake gate can read it).`, 'Set "perDay" above 0, or give it "growth", a mission prize or a mission roll.');
|
|
1810
|
+
} else if (!ofCollectibles && !f.rate.shared && perDay !== null && (perDay * 1_000_000n) / 10n ** BigInt(m.tokens.get(f.stakes)?.decimals ?? 0) === 0n) warn('W-FARM', `${where}.rate.perDay`, `${label(f)}’s rate of ${f.rate.perDay} a day is less than one base unit for each 1,000,000 base units staked.`, 'Raise "perDay".');
|
|
1811
|
+
if (f.rate.minted && rewardsToken && !rewardsToken.gameMints) refuse('E-MINT-NOT-OURS', `${where}.rate.minted`, `${label(f)} mints its rewards, but the project cannot mint ${rewardsToken.symbol}.`, 'Pay the rate from its rewards vault ("minted": false), or reward in a token with source "project".');
|
|
1812
|
+
if (!f.rate.minted) warn('W-FARM-RATE', `${where}.rate`, `${label(f)} pays its fixed rate from what its rewards vault holds free while it lasts: what it can’t cover is not owed later, and every seat earns less once it runs dry.`, `Keep it funded ("farm:${f.key}" in a split or a schedule), or mint the rewards.`);
|
|
1813
|
+
else rateRoom(f, warn, m);
|
|
1814
|
+
if (f.rate.halveEvery !== undefined) fourByteSeconds(f.rate.halveEvery, `${where}.rate.halveEvery`);
|
|
1815
|
+
if (f.rate.shared) warn('W-FARM-RATE', `${where}.rate.shared`, `${label(f)}’s rate of ${f.rate.perDay} a day is the whole farm’s, shared by weight while anything is staked${f.rate.halveEvery ? `, halving every ${f.rate.halveEvery} from when it opens` : ''}: an emission budget, so each seat’s share falls as more stake.`);
|
|
1816
|
+
else if (f.rate.halveEvery) warn('W-FARM-RATE', `${where}.rate.halveEvery`, `${label(f)}’s rate halves every ${f.rate.halveEvery} from when it opens, for every seat.`);
|
|
1817
|
+
}
|
|
1818
|
+
const names = growths.flatMap((g) => [g.attribute ?? 'xp', ...((g.levels ?? []).length ? [g.level ?? 'level'] : [])]);
|
|
1819
|
+
if (names.some((n, i) => names.indexOf(n) !== i)) refuse('E-FARM', `${where}.growth`, `${label(f)} grows the same attribute twice (${names.join(', ')}): each growth writes names of its own.`, 'Give each growth its own "attribute" (and "level").');
|
|
1820
|
+
for (const [i, g] of growths.entries()) {
|
|
1821
|
+
const at = Array.isArray(f.growth) ? `${where}.growth[${i}]` : `${where}.growth`;
|
|
1822
|
+
const own = RESERVED_ATTRIBUTES;
|
|
1823
|
+
const [points, level] = [g.attribute ?? 'xp', g.level ?? 'level'];
|
|
1824
|
+
if (own.includes(points) || (g.levels?.length && own.includes(level)) || ((g.levels ?? []).length && points === level))
|
|
1825
|
+
refuse('E-FARM', at, `${label(f)} writes its points under “${points}” and its level under “${level}”: two names of the game’s own, not the program’s (${own.join(', ')}).`, 'Use names such as "xp" and "level".');
|
|
1826
|
+
if ((g.levels ?? []).some((n, i, all) => i > 0 && n <= all[i - 1])) refuse('E-FARM', `${at}.levels`, `${label(f)}’s levels must need more points each.`, 'List the points each level needs, rising: [100, 300, 600].');
|
|
1827
|
+
if (!ofCollectibles && !(f.boosts ?? []).length) warn('W-FARM', at, `${label(f)} stakes a token: only a collectible put on a seat as its booster grows.`);
|
|
1828
|
+
}
|
|
1829
|
+
if (f.runs !== undefined) {
|
|
1830
|
+
const runs = fromNowSeconds(f.runs, `${where}.runs`);
|
|
1831
|
+
for (const [i, t] of tiers.entries())
|
|
1832
|
+
if (toSeconds(t.lock ?? 0) > 0 && toSeconds(t.lock ?? 0) >= runs) warn('W-FARM', `${where}.tiers[${i}].lock`, `${label(f)} runs ${f.runs}, and its tier ${i} locks ${t.lock}: nobody could ever take it (a seat must be free by the farm’s end, and the farm takes none at its end).`, 'Shorten the lock, or let the farm run longer.');
|
|
1833
|
+
const end = farmEndWords(f);
|
|
1834
|
+
warn('W-FARM', `${where}.runs`, `${label(f)} runs ${f.runs} from when it goes live: it takes no stake at its end or after it (nor one at a tier whose lock would run past it), and pays and grows nothing more; seats leave and harvest what they were owed as before.${end ? ` It shares what lands: ${end}.` : ''}`);
|
|
1835
|
+
}
|
|
1836
|
+
// Missions' prizes (prize.rs): a tier's, given as a collectible that stayed its lock leaves its seat.
|
|
1837
|
+
if (f.prize) refuse('E-FARM', `${where}.prize`, `${label(f)} names where its prizes come from: each tier names its whole prize.`, 'Move it into each tier: "prize": { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" } (or an item, an NFT, a roll of a chest).');
|
|
1838
|
+
for (const [i, t] of tiers.entries()) {
|
|
1839
|
+
if (!t.prize) continue;
|
|
1840
|
+
const at = `${where}.tiers[${i}].prize`;
|
|
1841
|
+
if (typeof t.prize !== 'object') refuse('E-FARM', at, `${label(f)}’s tier ${i} pays ${t.prize}: a tier names its whole prize.`, 'Write "prize": { "gives": "tokens", "amount": n, "from": "mint" or "pool:<key>" }.');
|
|
1842
|
+
else prizeCheck(t.prize, at, `${label(f)}’s tier ${i}`, 'E-FARM');
|
|
1843
|
+
if (!ofCollectibles) refuse('E-FARM', at, `${label(f)} stakes a token: a mission’s prize is for a collectible that stays its tier’s lock.`, 'Make it a farm of collectibles, or remove the prizes.');
|
|
1844
|
+
if (toSeconds(t.lock ?? 0) === 0) refuse('E-FARM', at, `${label(f)}’s tier ${i} gives a prize with no lock: staking and leaving at once would give it again and again.`, 'Give the mission a lock, such as "1d".');
|
|
1845
|
+
}
|
|
1846
|
+
for (const [i, t] of tiers.entries()) {
|
|
1847
|
+
if (!t.roll) continue;
|
|
1848
|
+
const at = `${where}.tiers[${i}].roll`;
|
|
1849
|
+
if (!ofCollectibles) refuse('E-FARM', at, `${label(f)} stakes a token: a mission’s roll is for a collectible leaving its seat.`, 'Make it a farm of collectibles, or remove the roll.');
|
|
1850
|
+
if (toSeconds(t.lock ?? 0) === 0) refuse('E-FARM', at, `${label(f)}’s tier ${i} rolls with no lock: staking and leaving at once would roll it again and again.`, 'Give the mission a lock, such as "1d".');
|
|
1851
|
+
if (!m.box.get(t.roll)) refuse('E-FARM', at, `${label(f)}’s tier ${i} rolls “${t.roll}”, which is no box.`, 'Name a box of this spec.');
|
|
1852
|
+
else if (!m.box.get(t.roll).rollsOn) refuse('E-FARM', at, `${label(f)}’s tier ${i} rolls “${t.roll}” on the NFT leaving it, but the box rolls on none: a chest the mission brings back is its prize.`, `Write "prize": { "gives": "roll", "box": "${t.roll}" } instead, or give the box "rollsOn".`);
|
|
1853
|
+
else if ((f.custody ?? 'escrow') === 'watch' && (m.box.get(t.roll).outcomes ?? []).some((o) => o.then && o.then !== 'back'))
|
|
1854
|
+
warn('W-ROLL-CUSTODY', at, `${label(f)} watches NFTs left free, and its mission’s box can burn or give one away: an NFT its staker sold before leaving is never rolled (it earns nothing either); one still held is taken into the game’s hands for the roll.`);
|
|
1855
|
+
}
|
|
1856
|
+
// The times a farm of collectibles keeps on the NFT: its lock and an upkeep.
|
|
1857
|
+
const times = [['lockAttribute', f.lockAttribute], ['upkeepAttribute', f.upkeepAttribute]].filter(([, key]) => key !== undefined);
|
|
1858
|
+
for (const [field, key] of times) {
|
|
1859
|
+
if (!ofCollectibles) refuse('E-FARM', `${where}.${field}`, `${label(f)} stakes a token, which has nowhere to keep a time: only a farm of collectibles writes one on what it stakes.`, `Remove "${field}", or make it a farm of collectibles.`);
|
|
1860
|
+
if (RESERVED_ATTRIBUTES.includes(key)) refuse('E-FARM', `${where}.${field}`, `“${key}” is a number the program writes itself.`, 'Use a name of the game’s own, such as "free_at" or "upkeep_until".');
|
|
1861
|
+
if (names.includes(key)) refuse('E-FARM', `${where}.${field}`, `${label(f)} grows “${key}” and keeps a time under it too.`, 'Give the time a name of its own.');
|
|
1862
|
+
}
|
|
1863
|
+
if (f.lockAttribute !== undefined && f.lockAttribute === f.upkeepAttribute) refuse('E-FARM', `${where}.upkeepAttribute`, `${label(f)} keeps its lock and its upkeep under the same name, “${f.lockAttribute}”.`, 'Give each its own name.');
|
|
1864
|
+
if (f.upkeepAttribute !== undefined && ofCollectibles) {
|
|
1865
|
+
const sells = m.recipes.some((r) => (r.raises ?? []).some((x) => x.key === f.upkeepAttribute && x.fromNow && toBy(x.by) > 0));
|
|
1866
|
+
warn('W-FARM-UPKEEP', `${where}.upkeepAttribute`, `${label(f)} pays and grows a collectible only until the time under “${f.upkeepAttribute}”: one staked dry counts for nothing until it is topped up and its seat refreshed, and a seat earns nothing after its upkeep runs out, however late it is next touched (no refresh is needed at its end).${sells ? '' : ' Nothing in this spec sells it: only the authority (Set) could.'}`, sells ? undefined : `Add a recipe that raises "${f.upkeepAttribute}" with "fromNow": true.`);
|
|
1867
|
+
// A known limit of the program: a recipe with a pay line waits while the project is paused or suspended
|
|
1868
|
+
// (craft.rs), and the upkeep's clock keeps running. With every top-up paid and capped below a mission's lock, a
|
|
1869
|
+
// switch on for longer than a player could top up ahead dries the seat, and a seat that ran dry before its lock
|
|
1870
|
+
// ended never brings its tier's prize (farm.rs: `stayed`), even once it is topped up again.
|
|
1871
|
+
const refuels = m.recipes.filter((r) => r.active !== false).flatMap((r) => (r.raises ?? []).filter((x) => x.key === f.upkeepAttribute && x.fromNow && toBy(x.by) > 0).map((x) => ({ r, x })));
|
|
1872
|
+
const prized = tiers.filter((t) => t.prize && toSeconds(t.lock ?? 0) > 0);
|
|
1873
|
+
if (refuels.length && prized.length && refuels.every(({ r }) => (r.in ?? []).some((l) => l.takes === 'pay'))) {
|
|
1874
|
+
const ahead = Math.max(...refuels.map(({ x }) => (x.upTo === undefined ? Infinity : toSeconds(x.upTo))));
|
|
1875
|
+
const lock = prized.reduce((most, t) => (toSeconds(t.lock) > toSeconds(most.lock) ? t : most));
|
|
1876
|
+
if (ahead < toSeconds(lock.lock))
|
|
1877
|
+
warn('W-FARM-REFUEL', `${where}.upkeepAttribute`, `${label(f)}’s missions bring a prize (a ${lock.lock} lock) only to a seat whose “${f.upkeepAttribute}” never ran out, and every recipe that tops it up takes a payment, which waits while the project is paused or suspended (a switch the platform holds too), while the upkeep keeps running down. A player can top it up only ${refuels.map(({ r, x }) => `${x.upTo} ahead with ${label(r)}`).join(', ')}, so a switch on for longer than that dries the seat: its mission’s prize is lost for good, and what it was credited after the lapse is given back, even once it is topped up again.`, `Add a top-up recipe with no "pay" line (one paid in nothing goes through while the project is suspended), or let a player top up a whole mission ahead ("upTo" of at least ${lock.lock}, longer than any suspension you would expect).`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (f.gate && !ofCollectibles) refuse('E-FARM', `${where}.gate`, `${label(f)} stakes a token: a gate only says which collectibles a farm of collectibles takes.`, 'Remove the gate, or use a boost to favour holders.');
|
|
1881
|
+
const gates = listOf(f.gate);
|
|
1882
|
+
for (const [i, r] of gates.entries()) rule(r, Array.isArray(f.gate) ? `${where}.gate[${i}]` : `${where}.gate`, label(f));
|
|
1883
|
+
// No lock rule for rewards that land: a sharing farm releases each over its release span, and a fixed rate pays by
|
|
1884
|
+
// the second, so a stake just before a reward lands and gone right after takes only what was released meanwhile.
|
|
1885
|
+
// A farm whose rewards are a share of what players pay hands holders the project's revenue.
|
|
1886
|
+
const revenue = fills.filter((x) => /^(box|item|payment):/.test(x.by));
|
|
1887
|
+
if (revenue.length)
|
|
1888
|
+
warn('W-REVENUE-SHARE', where, `${label(f)} pays stakers a share of what players spend (${revenue.map((x) => x.by).join(', ')}). Paying holders out of a project’s revenue is what regulators look at when they decide whether a token is a security.`, 'Fine on devnet. Before mainnet, ask counsel, or reward stakers from emissions (a schedule that mints a game token) rather than from sales.');
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// ---- contests (contest.rs)
|
|
1892
|
+
for (const c of m.contests) {
|
|
1893
|
+
const where = `economy.contests.${c.key}`;
|
|
1894
|
+
tooMany((c.deciders ?? []).length, LIMITS.deciders, 'E-CONTEST', `${where}.deciders`, `${label(c)} has ${(c.deciders ?? []).length} deciders`, 'BadContest', 'Name fewer deciders.');
|
|
1895
|
+
const tok = token(c.currency, `${where}.currency`);
|
|
1896
|
+
confidential(tok, `${where}.currency`, label(c), 'E-CONTEST');
|
|
1897
|
+
startsFrozen(tok, `${where}.currency`, label(c), 'E-CONTEST', 'BadContest');
|
|
1898
|
+
// Entries close no later than the result is due, and with deciders before it: a decider's verdict is taken only
|
|
1899
|
+
// once entries close (contest.rs, BadContest). With deciders, a side left out is an hour from the other (plan.mjs
|
|
1900
|
+
// `contestArgs`).
|
|
1901
|
+
const closes = seconds(c.entriesClose, `${where}.entriesClose`);
|
|
1902
|
+
const due = seconds(c.resultDue, `${where}.resultDue`);
|
|
1903
|
+
if (c.entriesClose !== undefined && c.resultDue !== undefined && closes > due)
|
|
1904
|
+
refuse('E-CONTEST', where, `${label(c)}’s entries close after its result is due (BadContest).`, 'Close entries before the result is due.');
|
|
1905
|
+
else if (Math.max(closes, due) > MAX_CONTEST_SECONDS)
|
|
1906
|
+
refuse('E-CONTEST', where, `${label(c)}’s result is due more than 5 years after it opens: the program refuses to open it (BadContest).`, 'Have its result due within 5 years.');
|
|
1907
|
+
else if ((c.deciders ?? []).length && c.entriesClose !== undefined && c.resultDue !== undefined && closes >= due)
|
|
1908
|
+
refuse('E-CONTEST', where, `${label(c)} has deciders, whose verdict is taken only once entries close, and its entries close when its result is due: the program refuses to open it (BadContest).`, 'Close entries before the result is due, or leave one of "entriesClose" and "resultDue" out (an hour apart).');
|
|
1909
|
+
if (c.sideNames && c.sideNames.length !== c.sides) warn('W-CONTEST', `${where}.sideNames`, `${label(c)} has ${c.sides} sides and ${c.sideNames.length} names for them.`, 'Name each side once.');
|
|
1910
|
+
split(c.split, tok, `${where}.split`, { what: label(c) });
|
|
1911
|
+
rule(c.gate, `${where}.gate`, label(c));
|
|
1912
|
+
const cp = c.prize ? prizeCheck(c.prize, `${where}.prize`, label(c), 'E-CONTEST') : null;
|
|
1913
|
+
// One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
|
|
1914
|
+
const scarce = cp && (cp.gives === 'nft' || (cp.gives === 'item' && Number(m.item.get(cp.item)?.supply ?? 0) > 0) || prizePool(cp));
|
|
1915
|
+
if (scarce) warn('W-CONTEST-PRIZE', `${where}.prize`, `${label(c)} gives ${prizeWords(cp, m)} to each winning entry as it is claimed: ${cp.gives === 'nft' ? 'there is one, so the first winning entry claimed takes it and the others get none' : 'when it runs short, the entries claimed first take it'}, and anyone may claim the entries in any order.`, cp.gives === 'nft' ? 'For one winner, use a contest whose sides are the players (a gate of wallets), or give a roll of a box instead.' : 'Keep enough for every winner, or give a roll of a box.');
|
|
1916
|
+
if (c.stakeNft) {
|
|
1917
|
+
// A pot of NFTs: no split and no stake of tokens (contest.rs BadContest).
|
|
1918
|
+
if (sharesOf(c.split).length) refuse('E-CONTEST', `${where}.split`, `${label(c)}’s entries stake NFTs: there is no money to take shares of (BadContest).`, 'Remove the split.');
|
|
1919
|
+
if (c.stake !== undefined) refuse('E-CONTEST', `${where}.stake`, `${label(c)}’s entries stake one NFT each, not tokens (BadContest).`, 'Remove "stake".');
|
|
1920
|
+
rule(c.stakeNft, `${where}.stakeNft`, `${label(c)}’s stake`);
|
|
1921
|
+
warn('W-NFT-PRICE', `${where}.stakeNft`, `${label(c)}’s entries each stake one NFT, and the NFTs are the pot: each winner takes its own back and its share of the losers’ (${c.losersEach ? `at most ${c.losersEach}` : 'all of them, shared'}; the first to enter on the winning side take one more when they don’t divide evenly), each picking which, and the losers’ NFTs the winners leave go to whoever opened it. Losers send nothing. The project holds every NFT until it is claimed.`);
|
|
1922
|
+
}
|
|
1923
|
+
const deciders = c.deciders ?? [];
|
|
1924
|
+
deciders.forEach((k, i) => isAddress(k) || refuse('E-REF', `${where}.deciders[${i}]`, `${label(c)}’s decider “${k}” is not a public address.`, 'Write the results server’s public key (never its private key).'));
|
|
1925
|
+
// A contest of NFTs counts its pot in NFTs, so its least pot is a whole number of them.
|
|
1926
|
+
const minPot = c.stakeNft ? wholeCount(c.minPot) : units(c.minPot, tok, `${where}.minPot`, `${label(c)}’s least pot`);
|
|
1927
|
+
if (c.stakeNft && minPot === null) refuse('E-CONTEST', `${where}.minPot`, `${label(c)}’s entries stake NFTs, so its least pot counts NFTs: ${c.minPot} is not a whole number of them.`, 'Write how many NFTs its sides must hold, like 4.');
|
|
1928
|
+
units(c.stake, tok, `${where}.stake`, `${label(c)}’s stake`);
|
|
1929
|
+
if (c.sides === 1) {
|
|
1930
|
+
if (!minPot) warn('W-CONTEST', `${where}.minPot`, `${label(c)} has one side and no goal: it is a collection that pays its split whatever it raises.`, 'Give it a "minPot": short of it, everyone takes their money back (an all-or-nothing crowdfund).');
|
|
1931
|
+
if (sharesOf(c.split).reduce((n, s) => n + (s.bps ?? 0), 0) < 10_000) warn('W-CONTEST', `${where}.split`, `${label(c)} has one side, so its backers share back what its split leaves: a crowdfund’s split sends all of it to whoever it funds.`, 'Send 100% to the wallet it funds.');
|
|
1932
|
+
} else warn('W-CHANCE-PRIZE', where, `${label(c)} lets players put money on an outcome: that is betting, which is regulated in most places once it is real money (state rules on prediction markets are in flux). loot runs on devnet only.`, 'Before mainnet: counsel, an age and region gate, and a name that does not call it an investment.');
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// ---- auctions (auction.rs)
|
|
1936
|
+
for (const a of m.auctions) {
|
|
1937
|
+
const where = `economy.auctions.${a.key}`;
|
|
1938
|
+
const tok = token(a.currency, `${where}.currency`);
|
|
1939
|
+
confidential(tok, `${where}.currency`, label(a), 'E-AUCTION');
|
|
1940
|
+
startsFrozen(tok, `${where}.currency`, label(a), 'E-AUCTION', 'BadAuction');
|
|
1941
|
+
const reserve = a.bidNfts ? BigInt(Number.isInteger(a.reserve) ? a.reserve : 1) : units(a.reserve, tok, `${where}.reserve`, `${label(a)}’s reserve`);
|
|
1942
|
+
if (a.bidNfts) {
|
|
1943
|
+
// Bids of NFT bundles (auction.rs): no split, and a reserve a bid of up to three can reach.
|
|
1944
|
+
if (a.reserve !== undefined && !(Number.isInteger(a.reserve) && a.reserve >= 1 && a.reserve <= MAX_BID_NFTS)) refuse('E-AUCTION', `${where}.reserve`, `${label(a)}’s bids are bundles of NFTs: its reserve counts NFTs, from 1 to ${MAX_BID_NFTS}, the most one bid holds (BadAuction).`, `Write a reserve of 1 to ${MAX_BID_NFTS}.`);
|
|
1945
|
+
if (sharesOf(a.split).length) refuse('E-AUCTION', `${where}.split`, `${label(a)}’s bids are NFTs: there is no money to take shares of (BadAuction).`, 'Remove the split.');
|
|
1946
|
+
rule(a.bidNfts, `${where}.bidNfts`, `${label(a)}’s bids`);
|
|
1947
|
+
outsideAttribute(a.bidNfts, `${where}.bidNfts`, `${label(a)}’s bids`);
|
|
1948
|
+
warn('W-NFT-PRICE', `${where}.bidNfts`, `${label(a)}’s bids are bundles of up to three Core or Token Metadata NFTs (never compressed ones): the bundle with the most wins, a topped bundle goes back to its bidder at once, and the winning NFTs go to ${a.lot?.asset ? 'the seller' : 'the treasury'}. The least raise is in NFTs too (always at least one more).`);
|
|
1949
|
+
}
|
|
1950
|
+
percent(a.minRaise, `${where}.minRaise`);
|
|
1951
|
+
const runs = seconds(a.runs, `${where}.runs`);
|
|
1952
|
+
const extend = seconds(a.extendBy ?? '10m', `${where}.extendBy`);
|
|
1953
|
+
let start = now;
|
|
1954
|
+
try {
|
|
1955
|
+
start = toTime(a.start, now);
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
refuse(err.code, `${where}.start`, err.message, err.fix);
|
|
1958
|
+
}
|
|
1959
|
+
if (runs === 0) refuse('E-AUCTION', `${where}.runs`, `${label(a)} runs for no time at all: its end must come after its start (BadAuction).`, 'Give it a time to run, such as "24h".');
|
|
1960
|
+
else if (start + runs <= now) refuse('E-AUCTION', `${where}.start`, `${label(a)} would be over before it goes live (BadAuction).`, 'Start it at "publish", or later.');
|
|
1961
|
+
// Nothing but a higher bid hands a high bid back before the end: an end, or a push of it, more than five years off
|
|
1962
|
+
// would hold the bid and the lot that long, and the program refuses it (auction.rs).
|
|
1963
|
+
if (runs > 0 && start + runs - now > MAX_CONTEST_SECONDS) refuse('E-AUCTION', `${where}.${start - now >= MAX_CONTEST_SECONDS ? 'start' : 'runs'}`, `${label(a)} ends more than 5 years after go-live: until its end only a higher bid hands a high bid back, so a bid and the lot would be held that long (BadAuction).`, 'End it within 5 years.');
|
|
1964
|
+
if (extend > MAX_CONTEST_SECONDS) refuse('E-AUCTION', `${where}.extendBy`, `${label(a)} moves its end by more than 5 years after a bid: one bid would hold the lot and itself that long (BadAuction).`, 'Use an extension of minutes, such as "10m".');
|
|
1965
|
+
if (a.lot?.item) {
|
|
1966
|
+
const it = item(a.lot.item, `${where}.lot.item`);
|
|
1967
|
+
if (it && it.active === false) refuse('E-AUCTION', `${where}.lot.item`, `${label(a)} sells ${label(it)}, which is switched off (LotRefused).`, `Switch ${label(it)} on.`);
|
|
1968
|
+
if (it?.kind === 'normal') warn('W-AUCTION', `${where}.lot.item`, `${label(a)} sells ${label(it)}, a normal item: the program names the winner, and the game’s backend must deliver it from the receipt.`);
|
|
1969
|
+
} else if (a.lot?.roll) {
|
|
1970
|
+
// A mystery lot: open_auction refuses one whose box is not written (LotRefused).
|
|
1971
|
+
prizeBox(a.lot.roll, `${where}.lot.roll`, label(a), 'E-AUCTION');
|
|
1972
|
+
warn('W-AUCTION', `${where}.lot.roll`, `${label(a)} sells a roll of a box: the winner pays for whatever it rolls, and gets nothing more if what it gives has run out by the end (an empty stash, a sold-out item).`, 'Keep a stash or item for this auction alone, and say on the page what the box can give, with its odds.');
|
|
1973
|
+
randomItems = true;
|
|
1974
|
+
} else if (a.lot?.asset) {
|
|
1975
|
+
if (!a.lot.collection && !m.live.project) refuse('E-AUCTION', `${where}.lot.asset`, `${label(a)} sells a collectible of the game’s own collection, which is only made when the economy goes live: there is none to sell yet.`, 'Sell a new unit of an item ({ "item": key }) now, or add this auction once the economy is live and your wallet holds the collectible.');
|
|
1976
|
+
}
|
|
1977
|
+
split(a.split, tok, `${where}.split`, { what: label(a) });
|
|
1978
|
+
rule(a.gate, `${where}.gate`, label(a));
|
|
1979
|
+
if (extend > 0 && extend >= runs && runs > 0) warn('W-AUCTION', `${where}.extendBy`, `${label(a)} moves its end by ${a.extendBy ?? '10m'} after every bid, as long as it runs at all: every bid restarts the clock.`, 'Use an extension much shorter than the run, such as "10m" for a day.');
|
|
1980
|
+
if (reserve === 0n && !a.bidNfts) warn('W-AUCTION', `${where}.reserve`, `${label(a)} has no reserve: one bid of the smallest unit wins it if nobody else bids.`, 'Set "reserve" to the least you would sell it for.');
|
|
1981
|
+
const sameItem = m.auctions.filter((x) => a.lot?.item && x.lot?.item === a.lot.item);
|
|
1982
|
+
const it = a.lot?.item && m.item.get(a.lot.item);
|
|
1983
|
+
if (it?.supply && sameItem.length > it.supply && sameItem[0] === a)
|
|
1984
|
+
refuse('E-AUCTION', `${where}.lot.item`, `${sameItem.length} auctions sell ${label(it)}, and only ${it.supply} can ever be made: each auction keeps its unit from the moment it opens (LotRefused).`, `Raise ${label(it)}’s supply, or auction fewer.`);
|
|
1985
|
+
warn('W-AUCTION-SHILL', where, `Anyone can bid on ${label(a)}, the seller and the project included, to push the price up (shill bidding); the program cannot tell them apart.`, 'Set an honest reserve instead, and say so where players bid.');
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// ---- holds (hold.rs)
|
|
1989
|
+
for (const h of m.holds) {
|
|
1990
|
+
const where = `economy.holds.${h.key}`;
|
|
1991
|
+
const it = item(h.item, `${where}.item`);
|
|
1992
|
+
if (it && (!isCollectible(it) || it.soulbound)) refuse('E-HOLD', `${where}.item`, `${label(h)} holds ${label(it)}, which is ${it.soulbound ? 'soulbound' : 'not a collectible'}: only a collectible that can move can be held.`, 'Hold a collectible that is not soulbound.');
|
|
1993
|
+
seconds(h.for, `${where}.for`);
|
|
1994
|
+
if (h.until !== undefined) {
|
|
1995
|
+
try {
|
|
1996
|
+
if (toTime(h.until, now) < now) warn('W-HOLD', `${where}.until`, `${label(h)}’s holds end before the economy goes live: each one is over at once, and anyone can send its NFT home.`);
|
|
1997
|
+
} catch (err) {
|
|
1998
|
+
refuse(err.code, `${where}.until`, err.message, err.fix);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// ---- grants (grant.rs)
|
|
2004
|
+
for (const g of m.grants) {
|
|
2005
|
+
const where = `economy.grants.${g.key}`;
|
|
2006
|
+
const old = g.gives ?? {};
|
|
2007
|
+
if (g.prize && g.gives) refuse('E-GRANT', `${where}.gives`, `${label(g)} names a "prize" and "gives": one is its prize.`, 'Keep "prize".');
|
|
2008
|
+
if (!g.prize && [old.item, old.pool, old.roll].filter(Boolean).length > 1) refuse('E-GRANT', `${where}.gives`, `${label(g)} gives more than one thing.`, 'Give one "prize".');
|
|
2009
|
+
else if (!g.prize && !g.gives) refuse('E-GRANT', `${where}.prize`, `${label(g)} gives nothing.`, 'Add a "prize": { "gives": "item", "item": key }, { "gives": "tokens", … }, { "gives": "nft", … } or { "gives": "roll", "box": key }.');
|
|
2010
|
+
const gives = prizeCheck(g.prize ?? g.gives, `${where}.${g.prize ? 'prize' : 'gives'}`, label(g), 'E-GRANT') ?? {};
|
|
2011
|
+
const vouchers = g.vouchers ?? [];
|
|
2012
|
+
const keys = g.keys ?? [];
|
|
2013
|
+
const l = g.limits ?? {};
|
|
2014
|
+
const open = !vouchers.length && !keys.length && !(g.list ?? []).length;
|
|
2015
|
+
// List entries that name their own amount: tokens of a fixed amount only, each at most the grant's (OverPrize).
|
|
2016
|
+
for (const [i, e] of (g.list ?? []).entries()) {
|
|
2017
|
+
if (typeof e !== 'object' || e.amount === undefined) continue;
|
|
2018
|
+
const t = prizeToken(gives, m);
|
|
2019
|
+
if (gives.gives !== 'tokens') refuse('E-GRANT', `${where}.list[${i}].amount`, `${label(g)}’s list names an amount, but it gives ${gives.gives ?? 'nothing'}: only a grant of a fixed amount of tokens takes one.`, 'Remove "amount", or make the grant give tokens.');
|
|
2020
|
+
else if (t && units(e.amount, t, `${where}.list[${i}].amount`, 'A listed amount') > units(gives.amount, t, `${where}.prize.amount`, label(g))) refuse('E-GRANT', `${where}.list[${i}].amount`, `${label(g)}’s list gives ${e.wallet} ${e.amount}, more than the grant’s ${gives.amount} (OverPrize).`, 'Raise the grant’s amount to the largest listed, or lower this one.');
|
|
2021
|
+
}
|
|
2022
|
+
// A grant with no voucher key, no key list and no list is open: any wallet claims it, within its limits (grant.rs).
|
|
2023
|
+
if (open && showsNft(g.gate) && l.perPerson)
|
|
2024
|
+
warn('W-GRANT', `${where}.limits.perPerson`, `${label(g)} is open: "perPerson" counts only vouchers that name a person, so here it limits nothing (the wallet limits count each collectible shown).`, 'Remove "perPerson".');
|
|
2025
|
+
if (open && !l.perWallet && !l.perWalletEach?.count)
|
|
2026
|
+
warn('W-GRANT', where, `${label(g)} has no voucher key, no keys and no list, and limits no wallet: the first wallet to come can claim it again and again, up to its total${l.total ? '' : ' (it has none: until its funds or its pool run out)'}.`, 'Set "limits.perWallet" (1 is usual) to make it a one-per-wallet claim, or add "vouchers", "keys" or a "list".');
|
|
2027
|
+
if (open)
|
|
2028
|
+
warn('W-GRANT-OPEN', where, g.gate?.wallets ? `${label(g)} is open to the ${g.gate.wallets.length} wallets its gate lists, within its limits.` : g.gate?.list ? `${label(g)} is open to the wallets on its gate’s list, within its limits.` : g.gate?.staked ? `${label(g)} is open to any wallet with the stake its gate asks, within its limits. Someone who stakes from many wallets claims once per wallet.` : g.gate ? `${label(g)} is open: any wallet that shows what its gate asks can claim it, within its limits, and each collectible shown counts too, so passing one around claims nothing more.` : `${label(g)} is open: any wallet can claim it, within its limits. Someone with many wallets claims once per wallet.`, g.gate ? undefined : 'Give it a gate (a collectible each claimer must hold), or keep "total" small.');
|
|
2029
|
+
for (const [i, v] of vouchers.entries()) if (!isAddress(v)) refuse('E-GRANT', `${where}.vouchers[${i}]`, 'A voucher key must be a public address.', 'Write the signer’s public key (never its private key).');
|
|
2030
|
+
for (const [i, v] of keys.entries()) if (!isAddress(v)) refuse('E-GRANT', `${where}.keys[${i}]`, 'A key must be a public address.', 'Write each code’s public key (never its private key or the code itself).');
|
|
2031
|
+
if (!open && !l.perWallet && !l.perPerson && !l.total && !l.perDay && !l.perKey && !l.perWalletEach?.count)
|
|
2032
|
+
warn('W-GRANT-LIMITS', `${where}.limits`, `${label(g)} has no limits: a leaked voucher key can take everything its ${prizePool(gives) ? 'pool' : 'prize'} has.`, 'Set "perWallet" (1 is usual), and "total" or "perDay".');
|
|
2033
|
+
if (keys.length && !l.perKey) warn('W-GRANT-LIMITS', `${where}.limits.perKey`, `${label(g)} takes vouchers from ${keys.length} keys but limits no key: a code someone shares pays every wallet that uses it.`, 'Set "perKey": 1 for codes that pay once each.');
|
|
2034
|
+
// A list written in xsolana.json has every leaf at round zero, and a round pays a wallet once (grant.rs take_round).
|
|
2035
|
+
const rounds = (g.list ?? []).some((e) => typeof e === 'object' && e.round);
|
|
2036
|
+
if ((g.list ?? []).length && !vouchers.length && !keys.length && !rounds && ((l.perWallet ?? 1) > 1 || l.perWalletEach))
|
|
2037
|
+
warn('W-LIST-ONCE', `${where}.limits`, `${label(g)} pays each entry on its list once: a wallet listed once is paid once, whatever "perWallet" or "perWalletEach" say.`, 'List the wallet again under another "round" ({ "wallet", "amount", "round" }) for each payout, or name a results server’s key in "vouchers".');
|
|
2038
|
+
// Every entry of the list is a claim of its own, and "total" counts every claim (grant.rs): the entries past it are refused.
|
|
2039
|
+
if ((g.list ?? []).length && l.total && l.total < g.list.length)
|
|
2040
|
+
warn('W-GRANT-LIMITS', `${where}.limits.total`, `${label(g)} lists ${g.list.length} claims but pays ${l.total} in all ("total"): once ${l.total} are claimed, the rest of the list is refused.`, `Raise "total" to ${g.list.length}, or leave it out.`);
|
|
2041
|
+
if (l.perWalletEach && !(l.perWalletEach.count > 0)) refuse('E-GRANT', `${where}.limits.perWalletEach`, `${label(g)} limits each wallet in a window but names no count.`, 'Give "count" (how many claims a wallet makes in each window) and "every" (like "1d").');
|
|
2042
|
+
perWalletWindow(l, `${where}.limits`);
|
|
2043
|
+
for (const at of ['until', 'opens']) {
|
|
2044
|
+
if (!l[at]) continue;
|
|
2045
|
+
try {
|
|
2046
|
+
toTime(l[at], now);
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
refuse(err.code, `${where}.limits.${at}`, err.message, err.fix);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
// Its funds are SOL, which pays each claim's rent.
|
|
2052
|
+
const funds = g.fund === undefined ? 0n : units(g.fund, { decimals: 9 }, `${where}.fund`, `${label(g)}’s funds`);
|
|
2053
|
+
if (funds === 0n)
|
|
2054
|
+
warn('W-GRANT-FUNDS', `${where}.fund`, `${label(g)} has no funds: whoever pays for each claim (the winner, who always signs it, or anyone paying its fees for them) pays its rent as well as the player fee.`, 'Set "fund" (0.05 SOL covers about a dozen claims) so winners claim for only the player fee and the network fee, or top it up after it goes live: anyone sends SOL to the grant.');
|
|
2055
|
+
rule(g.gate, `${where}.gate`, label(g));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// ---- parts switched off: saved as a draft (waiting for a voucher key, a date), they do nothing once live, while the
|
|
2059
|
+
// person may hear about them as if they worked. A box only prizes, missions or recipes roll is meant to be off.
|
|
2060
|
+
const OFF = { items: 'buy it or be given it', boxes: 'open it', recipes: 'craft it', schedules: 'run it', grants: 'claim it' };
|
|
2061
|
+
for (const [list, what] of Object.entries(OFF))
|
|
2062
|
+
for (const x of m[list])
|
|
2063
|
+
if (x.active === false && !(list === 'boxes' && rolls.some((r) => r.box === x.key)) && !(list === 'schedules' && x.sealed))
|
|
2064
|
+
warn('W-OFF', `economy.${list}.${x.key}.active`, `${label(x)} is switched off: once live, nobody can ${what} until it is switched on and published again.`, 'Tell the person it is off and what it waits for, or switch it on ("active": true) once that is ready.');
|
|
2065
|
+
|
|
2066
|
+
// ---- decisions: what only the game knows
|
|
2067
|
+
for (const d of m.decisions) {
|
|
2068
|
+
const where = `economy.decisions.${d.key}`;
|
|
2069
|
+
if (d.does === 'payout' || d.does === 'burn') {
|
|
2070
|
+
const p = d.pool ? pool(d.pool, `${where}.pool`) : refuse('E-DECISION', where, `${label(d)} ${d.does === 'payout' ? 'pays out' : 'burns'} but does not say from which pool.`, 'Add "pool".');
|
|
2071
|
+
if (d.does === 'burn' && p && m.tokens.get(p.currency)?.wsol) refuse('E-WSOL-BURN', `${where}.pool`, `${label(d)} burns SOL, and wrapped SOL cannot be burned.`, 'Pay it out instead.');
|
|
2072
|
+
if (p && d.amount !== undefined) units(d.amount, m.tokens.get(p.currency), `${where}.amount`, `${label(d)}’s amount`);
|
|
2073
|
+
if (d.percent !== undefined) percent(d.percent, `${where}.percent`);
|
|
2074
|
+
}
|
|
2075
|
+
if (d.does === 'issue') {
|
|
2076
|
+
const tok = token(d.currency, `${where}.currency`);
|
|
2077
|
+
if (tok && !tok.gameMints) refuse('E-MINT-NOT-OURS', where, `${label(d)} mints ${tok.symbol}, but the project cannot mint it.`, 'Pay it from a pool instead, or use a token with source "project".');
|
|
2078
|
+
}
|
|
2079
|
+
if (['make', 'unmake', 'set'].includes(d.does)) {
|
|
2080
|
+
const it = d.item ? item(d.item, `${where}.item`) : refuse('E-DECISION', where, `${label(d)} works on a collectible but does not say which item.`, 'Add "item".');
|
|
2081
|
+
if (it && !isCollectible(it)) refuse('E-DECISION', `${where}.item`, `${label(d)} works on ${label(it)}, which is not a collectible.`, `Make ${label(it)} a collectible.`);
|
|
2082
|
+
if (it && d.does === 'unmake' && !it.burnable) refuse('E-DECISION', `${where}.item`, `${label(d)} burns ${label(it)}, but only items marked burnable can be burned by the game.`, `Mark ${label(it)} "burnable": true before any is issued.`);
|
|
2083
|
+
}
|
|
2084
|
+
if (d.does === 'award' && !(d.hold && m.hold.get(d.hold))) refuse('E-REF', `${where}.hold`, `${label(d)} awards a held collectible but names no hold that exists.`, 'Name one of "holds".');
|
|
2085
|
+
if ((d.does === 'decide' || d.does === 'open_contest') && !(d.contest && m.contest.get(d.contest))) refuse('E-REF', `${where}.contest`, `${label(d)} works on a contest but names none that exists.`, 'Name one of "contests".');
|
|
2086
|
+
if (d.proof && !m.payments.some((p) => p.key === d.proof.payment)) refuse('E-REF', `${where}.proof.payment`, `${label(d)} asks for proof of the payment “${d.proof.payment}”, which is not one of the economy’s payments.`, 'Name one of "payments" (the attempt players pay for).');
|
|
2087
|
+
if (d.signedBy === 'server' && m.authority === 'owner')
|
|
2088
|
+
warn('W-AUTHORITY', where, `${label(d)} is signed by your server, but the authority is still your own wallet: that would put your wallet’s key on a machine that signs by itself.`, 'Give the authority its own address ("keys.authority"), made for that server.');
|
|
2089
|
+
}
|
|
2090
|
+
const needs = [
|
|
2091
|
+
...m.decisions.map((d) => `${label(d)} (when ${d.when})`),
|
|
2092
|
+
...m.contests.map((c) => ((c.deciders ?? []).length ? `opening ${label(c)} (its deciders’ keys name who won)` : `naming who won ${label(c)}`)),
|
|
2093
|
+
...m.holds.map((h) => `saying where ${label(h)}’s collectibles go`),
|
|
2094
|
+
];
|
|
2095
|
+
if (needs.length)
|
|
2096
|
+
warn('W-AUTHORITY', 'economy.keys.authority', `This economy needs the authority while it runs, for what only the game knows: ${needs.join('; ')}. ${m.authority === 'owner' ? 'That is your wallet, approving each one.' : `That is the key ${m.authority}.`}`);
|
|
2097
|
+
const voucherKeys = [...new Set(m.grants.flatMap((g) => [...(g.vouchers ?? []), ...(g.keys ?? [])]))];
|
|
2098
|
+
if (voucherKeys.length)
|
|
2099
|
+
warn('W-VOUCHERS', 'economy.grants', `A voucher key signs who earned a grant: whatever holds it (your server, a play host) decides who gets rewards, within the limits the program keeps. The program does not check that a player really did what the voucher says. The keys: ${voucherKeys.join(', ')}.`);
|
|
2100
|
+
|
|
2101
|
+
if (randomItems && !chance) warn('W-CHANCE-ITEMS', 'economy', 'Players pay for a random collectible they can resell (a loot box or gacha). Some places treat paid loot boxes of tradeable items as gambling, and many require the odds to be shown. loot runs on devnet only; before mainnet, counsel and an age and region gate.', 'Tell the person in plain words: paid random items that can be resold may count as gambling in some places; show the odds on the page, and get a lawyer before real money.');
|
|
2102
|
+
if (chance) warn('W-CHANCE-PRIZE', 'economy', 'Players pay for a chance at money (a box that pays a prize, a lottery). That is gambling in many places once it is real money, and paid packs with a cash buyback are drawing lawsuits. loot runs on devnet only; before mainnet, counsel and an age and region gate.', 'Tell the person this in plain words before they go live: paying for a chance at money is gambling in many places, so before real money they need a lawyer and an age and region gate.');
|
|
2103
|
+
|
|
2104
|
+
for (const key of Object.keys(m.scenario.perDay ?? {})) {
|
|
2105
|
+
const [kind, name] = key.split(':');
|
|
2106
|
+
const list = { box: m.box, item: m.item, recipe: m.recipe, lottery: m.lottery, farm: m.farm, contest: m.contest, auction: m.auction, hold: m.hold, grant: m.grant, payment: new Map(m.payments.map((p) => [p.key, p])) }[kind];
|
|
2107
|
+
if (!list?.has(name)) warn('W-SCENARIO', `economy.scenario.perDay.${key}`, `The scenario counts “${key}”, which is not in the economy.`, 'Remove it, or fix the name.');
|
|
2108
|
+
}
|
|
2109
|
+
for (const key of Object.keys(m.scenario.staked ?? {})) if (!m.farm.has(key)) warn('W-SCENARIO', `economy.scenario.staked.${key}`, `The scenario stakes in “${key}”, which is not a farm.`, 'Remove it, or fix the name.');
|
|
2110
|
+
if (typeof m.authority === 'string' && m.authority !== 'owner' && !isAddress(m.authority)) refuse('E-SCHEMA', 'economy.keys.authority', 'The authority is not an address.', 'Write "owner" or a wallet’s public address.');
|
|
2111
|
+
|
|
2112
|
+
// ---- the owner's setup: each step goes whole in one transaction (plan.mjs `pack`), so a step bigger than a setup
|
|
2113
|
+
// transaction may be (a box of many bundles, a recipe of many lines) could never go live. Built as the plan builds
|
|
2114
|
+
// it, on stand-in addresses.
|
|
2115
|
+
if (!refusals.length) {
|
|
2116
|
+
let setup = null;
|
|
2117
|
+
try {
|
|
2118
|
+
setup = setupSteps(economy, { now });
|
|
2119
|
+
} catch {
|
|
2120
|
+
// A spec the steps can't be built from fails the same way when it is planned.
|
|
2121
|
+
}
|
|
2122
|
+
const ctx = setup && placeholderContext(setup.model);
|
|
2123
|
+
const lists = { box: m.box, item: m.item, recipe: m.recipe, schedule: new Map(m.schedules.map((x) => [x.key, x])), lottery: m.lottery, grant: m.grant, farm: m.farm, auction: m.auction, pool: m.pool, seed: m.pool, fund: m.grant, stash: m.stash, table: m.table };
|
|
2124
|
+
const paths = { box: 'boxes', item: 'items', recipe: 'recipes', schedule: 'schedules', lottery: 'lotteries', grant: 'grants', farm: 'farms', auction: 'auctions', pool: 'pools', seed: 'pools', fund: 'grants', stash: 'stashes', table: 'tables', token: 'tokens', traits: 'traits' };
|
|
2125
|
+
for (const step of setup?.steps ?? []) {
|
|
2126
|
+
let bytes;
|
|
2127
|
+
try {
|
|
2128
|
+
bytes = stepBytes(step.build(ctx), ctx);
|
|
2129
|
+
} catch {
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
if (bytes <= BUDGET) continue;
|
|
2133
|
+
const [kind, key] = [step.part.split(':')[0], step.part.split(':').slice(1).join(':')];
|
|
2134
|
+
const part = lists[kind]?.get(key);
|
|
2135
|
+
const named = part ? `The ${kind === 'seed' ? 'seed of the pool' : kind === 'fund' ? 'funds of the grant' : kind} ${label(part)}` : `The setup step “${step.says}”`;
|
|
2136
|
+
const fix = kind === 'box' ? 'Give it fewer outcomes, or fewer prizes an outcome, or split it into two boxes.' : kind === 'recipe' ? 'Give it fewer lines, or split it into two recipes.' : 'Make it smaller (fewer entries, shorter names and links), or split it in two.';
|
|
2137
|
+
refuse('E-TX-SIZE', paths[kind] ? `economy.${paths[kind]}${part ? `.${key}` : ''}` : 'economy', `${named} takes ${bytes} bytes to set up in one transaction, more than the ${BUDGET} a setup transaction may hold (${MAX_TRANSACTION_BYTES}, less ${WALLET_ROOM} kept for what the owner’s wallet adds), so it could never go live.`, fix);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// ---- pools that run dry at the scenario's rates (W-POOL-DRY above names a pool nothing fills): one that is filled
|
|
2142
|
+
// but pays out faster drains, and then skips or fails what it owes.
|
|
2143
|
+
if (!refusals.length) {
|
|
2144
|
+
let sim;
|
|
2145
|
+
try {
|
|
2146
|
+
sim = simulate(economy, { now });
|
|
2147
|
+
} catch {
|
|
2148
|
+
sim = null;
|
|
2149
|
+
}
|
|
2150
|
+
for (const p of sim?.pools ?? []) {
|
|
2151
|
+
if (p.lasts !== 'runs dry' || !(p.lastsDays < sim.scenario.days)) continue;
|
|
2152
|
+
const sym = m.tokens.get(p.token)?.symbol ?? p.token;
|
|
2153
|
+
const days = sim.scenario.days;
|
|
2154
|
+
const short = p.outPerDay - p.inPerDay;
|
|
2155
|
+
const need = !p.outs.some((o) => o.percentPerDay !== undefined) && short > 0 ? +(short * days).toFixed(6) : null;
|
|
2156
|
+
const takersText = p.outs.map((o) => o.by).join(', ');
|
|
2157
|
+
warn('W-POOL-RUNS-DRY', `economy.pools.${p.key}`, `At the scenario’s rates “${p.key}” pays out ${+p.outPerDay.toFixed(6)} ${sym} a day (${takersText}) and takes in ${+p.inPerDay.toFixed(6)}: its seed of ${p.seed} ${sym} is gone on day ${p.lastsDays} of ${days}. After that what it owes is skipped or fails until money comes in.`, `Seed it with ${need !== null ? `at least ${need} ${sym} (${days} days at that rate)` : 'more'}, fill it with a share of a sale (a split to "pool:${p.key}") or a schedule, or pay less from it.`);
|
|
2158
|
+
}
|
|
2159
|
+
for (const row of sim?.farms ?? []) farmRounding(m.farm.get(row.key), row, warn, m);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
return result();
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** A large number in words a person reads: 7,000 below a billion, 7×10¹⁵ above. */
|
|
2166
|
+
function many(x) {
|
|
2167
|
+
if (x < 1e9) return Math.round(x).toLocaleString('en-US');
|
|
2168
|
+
const exp = Math.floor(Math.log10(x));
|
|
2169
|
+
const digits = String(exp).replace(/\d/g, (d) => '⁰¹²³⁴⁵⁶⁷⁸⁹'[d]);
|
|
2170
|
+
return `${+(x / 10 ** exp).toFixed(1)}×10${digits}`;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* A sharing farm's rounding: a release is credited by weight in steps of the total
|
|
2175
|
+
* weight ÷ 10¹² base units of the reward, and what is below a step waits uncredited, freed in its vault when the
|
|
2176
|
+
* last seat leaves or the farm ends. A staked token with a great many base units (many decimals, a huge supply: a
|
|
2177
|
+
* memecoin) paying a reward of few units rounds every seat's share to nothing: an exact model of the farm found seats
|
|
2178
|
+
* losing all of rewards of 194 to 2,155 units at total weights around 7×10¹⁵. The weight is the most that can
|
|
2179
|
+
* plausibly be staked (the supply, else a billion whole tokens or the scenario's stake if larger) at the highest tier
|
|
2180
|
+
* and the largest boost; the reward, what lands in a release span (the farm's seed and a span of the steady inflow).
|
|
2181
|
+
*/
|
|
2182
|
+
function farmRounding(f, row, warn, m) {
|
|
2183
|
+
if (!f || f.rate || f.stakes === 'collectibles') return;
|
|
2184
|
+
const staked = m.tokens.get(f.stakes), reward = m.tokens.get(f.rewardsToken);
|
|
2185
|
+
if (staked?.decimals === undefined || reward?.decimals === undefined) return;
|
|
2186
|
+
const scenario = Number(m.scenario.staked?.[f.key] ?? 0);
|
|
2187
|
+
const whole = staked.supply !== undefined ? Number(staked.supply) : Math.max(1e9, scenario);
|
|
2188
|
+
const top = Math.max(1, ...(f.tiers ?? []).map((t) => Number(t.multiplier ?? 1)));
|
|
2189
|
+
const boost = (f.boosts ?? []).length ? 1 + Number(f.maxBoostPercent ?? 0) / 100 : 1;
|
|
2190
|
+
const weight = whole * 10 ** staked.decimals * top * boost;
|
|
2191
|
+
if (!(weight > 1e12)) return;
|
|
2192
|
+
const step = weight / 1e12;
|
|
2193
|
+
const span = releaseSeconds(f) / 86_400;
|
|
2194
|
+
const units = (Number(f.seed ?? 0) + Number(row.rewardsPerDay ?? 0) * span) * 10 ** reward.decimals;
|
|
2195
|
+
if (!(units > 0) || units >= 100 * step) return;
|
|
2196
|
+
const [ss, rs] = [staked.symbol ?? f.stakes, reward.symbol ?? f.rewardsToken];
|
|
2197
|
+
const basis = `${staked.supply !== undefined ? `its supply of ${many(whole)}` : whole > 1e9 ? `the scenario’s ${many(whole)} staked` : `a billion ${ss} (assumed when no supply is given)`} at ${staked.decimals} decimals${top > 1 ? `, at x${top}` : ''}${boost > 1 ? `, boosted +${f.maxBoostPercent}%` : ''}`;
|
|
2198
|
+
const lost = units < step
|
|
2199
|
+
? 'each seat’s share of it rounds down to nothing, so no seat is credited any of it, and it is freed in its vault when the last seat leaves or the farm ends'
|
|
2200
|
+
: `up to a step of it at a time waits uncredited until more adds up to a step, and what waits is freed in its vault when the last seat leaves or the farm ends, so the seats can lose up to ${Math.min(100, Math.round((step / units) * 100))}% of a release`;
|
|
2201
|
+
warn('W-FARM-ROUNDING', `economy.farms.${f.key}.stakes`, `${label(f)} shares its rewards by weight, and the stakes in it can weigh up to about ${many(weight)} base units of ${ss} (${basis}). At that weight the program can share a release only in steps of about ${many(step)} base units of ${rs}, and about ${many(units)} land in it over a release (${releaseWords(f)}): ${lost}.`, `Stake a token with fewer base units (fewer decimals or a smaller supply), or fund larger rewards: about ${many(100 * step)} base units of ${rs} (${(100 * step) / 10 ** reward.decimals >= 1 ? many((100 * step) / 10 ** reward.decimals) : +((100 * step) / 10 ** reward.decimals).toPrecision(3)} ${rs}) or more a release keeps the rounding under 1%.`);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* A fixed rate that mints: each payout mints what a seat is owed as far as the mint has room for under the most a
|
|
2206
|
+
* mint can ever hold (u64::MAX base units, less its supply), and what it can't mint is never owed later. What the rate
|
|
2207
|
+
* could mint is the rate times the most that can plausibly be staked (a token's supply, else a billion whole tokens or
|
|
2208
|
+
* the scenario's stake if larger; a collectible's supply, else 10,000 or the scenario's count), at the highest tier and
|
|
2209
|
+
* the largest boost, over the farm's run (ten years for a farm that never ends). Past a tenth of that room the warning
|
|
2210
|
+
* says the seats can come to be paid less than the rate.
|
|
2211
|
+
*/
|
|
2212
|
+
function rateRoom(f, warn, m) {
|
|
2213
|
+
if (!(Number(f.rate.perDay) > 0)) return;
|
|
2214
|
+
const reward = m.tokens.get(f.rewardsToken);
|
|
2215
|
+
if (reward?.decimals === undefined) return;
|
|
2216
|
+
const scenario = Number(m.scenario.staked?.[f.key] ?? 0);
|
|
2217
|
+
const ofCollectibles = f.stakes === 'collectibles';
|
|
2218
|
+
const staked = ofCollectibles ? m.item.get(f.item) : m.tokens.get(f.stakes);
|
|
2219
|
+
const known = Number(staked?.supply ?? 0) > 0;
|
|
2220
|
+
const count = known ? Number(staked.supply) : Math.max(ofCollectibles ? 10_000 : 1e9, scenario);
|
|
2221
|
+
const top = Math.max(1, ...(f.tiers ?? []).map((t) => Number(t.multiplier ?? 1)));
|
|
2222
|
+
const boost = (f.boosts ?? []).length ? 1 + Number(f.maxBoostPercent ?? 0) / 100 : 1;
|
|
2223
|
+
const days = f.runs ? toSeconds(f.runs) / 86_400 : 3650;
|
|
2224
|
+
const units = Number(f.rate.perDay) * (f.rate.shared ? 1 : count * top * boost) * days * 10 ** reward.decimals;
|
|
2225
|
+
const room = 2 ** 64;
|
|
2226
|
+
if (!(units >= room / 10)) return;
|
|
2227
|
+
const [rs, unit] = [reward.symbol ?? f.rewardsToken, ofCollectibles ? 'collectible' : (staked?.symbol ?? f.stakes)];
|
|
2228
|
+
const basis = `${known ? `a supply of ${many(count)}` : count > (ofCollectibles ? 10_000 : 1e9) ? `the scenario’s ${many(count)}` : ofCollectibles ? '10,000 collectibles' : `a billion ${unit}`} staked${top > 1 ? ` at x${top}` : ''}${boost > 1 ? `, boosted +${f.maxBoostPercent}%` : ''}, ${f.runs ? `over its ${f.runs}` : 'over ten years'}`;
|
|
2229
|
+
const whole = room / 10 ** reward.decimals;
|
|
2230
|
+
warn('W-FARM-MINT-ROOM', `economy.farms.${f.key}.rate`, `${label(f)} mints ${f.rate.perDay} ${rs} a day for each ${unit} at x1: at ${basis}, that is about ${many(units)} base units, ${units >= room ? 'more than' : `${Math.round((units / room) * 100)}% of`} the most a mint can ever hold (about ${many(room)} base units, ${many(whole)} ${rs} at ${reward.decimals} decimals). Each move mints only what the mint still has room for, and what it can’t mint is never owed later: once ${rs}’s supply nears that, every seat is paid less than the rate, then nothing.`, `Lower "perDay", give the farm an end ("runs"), or reward in a token with fewer decimals.`);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/** A one-line summary for each refusal and warning, as a person reads them. */
|
|
2234
|
+
export function describe({ refusals, warnings }) {
|
|
2235
|
+
return [...refusals.map((r) => `✗ ${r.code} ${r.message}\n Fix: ${r.fix}`), ...warnings.map((w) => `! ${w.code} ${w.message}${w.fix ? `\n ${w.fix}` : ''}`)].join('\n');
|
|
2236
|
+
}
|