@xgamesfun/solana 0.25.4 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/js/approve/details.mjs +52 -2
- package/js/approve/live-check.mjs +0 -15
- package/js/approve/server.mjs +5 -4
- package/js/chain/token2022.js +3 -2
- package/js/client/accounts.js +67 -25
- package/js/client/grant.js +7 -2
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +714 -182
- package/js/client/ix.js +617 -113
- package/js/client/sizes.js +35 -3
- package/js/economy/check.mjs +655 -242
- package/js/economy/cost.mjs +2 -4
- package/js/economy/explain.mjs +16 -2
- package/js/economy/guide.mjs +12 -12
- package/js/economy/model.mjs +133 -14
- package/js/economy/plan.mjs +36 -11
- package/js/economy/review.mjs +50 -8
- package/js/economy/schema.json +73 -29
- package/js/economy/simulate.mjs +116 -13
- package/js/mcp/tools.mjs +2 -2
- package/js/play/index.js +847 -296
- package/package.json +1 -1
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +12 -4
- package/skills/xsolana-economy/references/after-launch.md +1 -1
- package/skills/xsolana-economy/references/apps.md +11 -2
- package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
- package/skills/xsolana-economy/references/telling-the-person.md +21 -4
- package/skills/xsolana-economy/references/trust-and-cheating.md +15 -5
- package/types/chain/token2022.d.ts +3 -2
- package/types/client/accounts.d.ts +34 -11
- package/types/client/idl/loot.d.ts +25 -4
- package/types/client/ix.d.ts +217 -45
- package/types/economy/check.d.mts +5 -0
- package/types/economy/model.d.mts +75 -12
- package/types/economy/plan.d.mts +22 -1
- package/types/economy/simulate.d.mts +12 -1
- package/types/mcp/server.d.mts +12 -1
- package/types/mcp/tools.d.mts +12 -1
- package/types/play/index.d.ts +210 -144
package/js/economy/check.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
FEES,
|
|
20
20
|
rent as rentOf,
|
|
21
21
|
LIMITS,
|
|
22
|
+
ANSWER_HOLDS,
|
|
22
23
|
RESERVED_ATTRIBUTES,
|
|
23
24
|
destination,
|
|
24
25
|
fromUnits,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
releaseWords,
|
|
46
47
|
farmEndWords,
|
|
47
48
|
stashNfts,
|
|
49
|
+
stopsSales,
|
|
48
50
|
poolTakers,
|
|
49
51
|
sharesOf,
|
|
50
52
|
toBps,
|
|
@@ -669,14 +671,18 @@ export function buySize(b, m, { detail = false, count = 1 } = {}) {
|
|
|
669
671
|
* holds its key, and the approval page calls it "your wallet" until a wallet connects: no spec may name it. */
|
|
670
672
|
export const STAND_IN_OWNER = encodeBase58(sha256(utf8('loot-plan:owner')));
|
|
671
673
|
|
|
672
|
-
/** 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). */
|
|
673
|
-
const MAX_CONTEST_SECONDS = Number(IDL.constants.find(c => c.name === 'MAX_CONTEST_SECONDS')?.value ?? 157_680_000);
|
|
674
|
-
/** A sealed schedule's last run comes within this of going live (schedule.rs). */
|
|
675
|
-
const MAX_SCHEDULE_SECONDS = Number(IDL.constants.find(c => c.name === 'MAX_SCHEDULE_SECONDS')?.value ?? 315_360_000);
|
|
676
|
-
/** The most runs a sealed schedule makes (schedule.rs). */
|
|
677
674
|
/** The most places a run of a lottery's places given one by one holds (lottery.rs). */
|
|
678
|
-
const MAX_RUN_PLACES = Number(IDL.constants.find(c => c.name === 'MAX_RUN_PLACES')
|
|
679
|
-
|
|
675
|
+
const MAX_RUN_PLACES = Number(IDL.constants.find(c => c.name === 'MAX_RUN_PLACES').value);
|
|
676
|
+
|
|
677
|
+
/** How far off a time may land before the checker asks whether its unit is right: three years. The program
|
|
678
|
+
* takes any time a date holds; a time written in milliseconds lands thousands of years out and locks what waits on it. */
|
|
679
|
+
export const FAR_SECONDS = 3 * 365 * 86_400;
|
|
680
|
+
/** A Unix time in words, in English: "14 March 2031". Past what a date can show, the year alone. */
|
|
681
|
+
export function dateWords(seconds) {
|
|
682
|
+
const d = new Date(Number(seconds) * 1000);
|
|
683
|
+
if (Number.isNaN(d.getTime())) return `the year ${Math.floor(Number(seconds) / 31_557_600) + 1970}`;
|
|
684
|
+
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
|
|
685
|
+
}
|
|
680
686
|
|
|
681
687
|
/**
|
|
682
688
|
* Checks an economy spec. `now` (Unix seconds) dates "publish".
|
|
@@ -852,6 +858,25 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
852
858
|
);
|
|
853
859
|
return s;
|
|
854
860
|
};
|
|
861
|
+
/**
|
|
862
|
+
* A time that lands far off: the program takes it, and what waits on it (bids, tickets, NFTs, a vault) is
|
|
863
|
+
* locked until then. `at` is when it lands (Unix seconds), `written` what the spec says, `locks` what waits. Warned
|
|
864
|
+
* with the date in words, and as milliseconds when the number written reads as a time in milliseconds.
|
|
865
|
+
*/
|
|
866
|
+
const farTime = (at, where, what, written, locks) => {
|
|
867
|
+
if (!(at - now > FAR_SECONDS)) return;
|
|
868
|
+
// A plain number this far off is most likely milliseconds: a thousandth of it is what was meant.
|
|
869
|
+
const millis = typeof written === 'number' || /^\d+$/.test(String(written ?? ''));
|
|
870
|
+
const meant = Math.round(Number(written) / 1000);
|
|
871
|
+
warn(
|
|
872
|
+
'W-FAR-TIME',
|
|
873
|
+
where,
|
|
874
|
+
`${what} lands on ${dateWords(at)}, ${Math.floor((at - now) / 31_536_000).toLocaleString('en-US')} years after go-live${millis ? `: ${written} reads like milliseconds, and the program counts seconds (${meant.toLocaleString('en-US')} seconds is ${meant >= 86_400 ? `${Math.round(meant / 86_400).toLocaleString('en-US')} days` : `${Math.round(meant / 3_600).toLocaleString('en-US')} hours`})` : ''}. The program takes any time, so ${locks} until then.`,
|
|
875
|
+
millis
|
|
876
|
+
? 'Write the time in seconds, or as a duration like "7d".'
|
|
877
|
+
: 'Check the unit and the date before you sign: a duration like "30d" or a date like "2027-01-01" is safest.',
|
|
878
|
+
);
|
|
879
|
+
};
|
|
855
880
|
/** Refuses a list over one of the program's caps (`LIMITS`), which it would refuse when the owner signs. */
|
|
856
881
|
const tooMany = (count, most, code, where, what, error, fix) => {
|
|
857
882
|
if (count > most) refuse(code, where, `${what}; the program takes at most ${most} (${error}).`, fix);
|
|
@@ -1012,22 +1037,29 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1012
1037
|
}
|
|
1013
1038
|
const p = t.powers ?? {};
|
|
1014
1039
|
percent(p.transferFeePercent, `${where}.powers.transferFeePercent`);
|
|
1040
|
+
// A token that can't be transferred moves only by a mint or a burn: nothing can be paid in it, staked, lent or
|
|
1041
|
+
// sealed in a vault, and a prize of it given from a pool never arrives. The program takes it; the project hears it.
|
|
1015
1042
|
if (p.nonTransferable)
|
|
1016
|
-
|
|
1017
|
-
'
|
|
1043
|
+
warn(
|
|
1044
|
+
'W-MINT-POWERS',
|
|
1018
1045
|
`${where}.powers`,
|
|
1019
|
-
`“${t.key}” cannot be transferred
|
|
1020
|
-
'Use another token.',
|
|
1046
|
+
`“${t.key}” cannot be transferred: nothing can be paid in it, staked in a farm, lent, bid or sealed in a vault, and a prize of it can only be minted to its winner. A share of it, a refund or a payout that has to move it never arrives, and a split prize of it minted to a contest's winners must be given up by each one.`,
|
|
1047
|
+
'Use it only as points the project mints to players, or use another token.',
|
|
1021
1048
|
);
|
|
1049
|
+
// What each power lets its holder do to a farm's stakes, a vest's vault, a box's payments or a prize.
|
|
1022
1050
|
const powers = [
|
|
1023
1051
|
p.freeze &&
|
|
1024
|
-
'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)',
|
|
1025
|
-
p.permanentDelegate &&
|
|
1052
|
+
'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), and so block a repayment or a refund',
|
|
1053
|
+
p.permanentDelegate &&
|
|
1054
|
+
'move or burn it out of any account, the game’s pools, a farm’s stakes and a sealed vest’s vault included',
|
|
1055
|
+
p.closeAuthority && 'close the mint and make it again with other powers',
|
|
1026
1056
|
p.transferHook &&
|
|
1027
|
-
'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)',
|
|
1057
|
+
'run its own program on every transfer, which can refuse one and so block a repayment or a refund (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)',
|
|
1028
1058
|
p.transferFeePercent > 0 &&
|
|
1029
1059
|
`keep ${p.transferFeePercent}% of every transfer (pools and prizes receive that much less)`,
|
|
1030
|
-
p.
|
|
1060
|
+
p.feeChangeable &&
|
|
1061
|
+
'raise its transfer fee, up to 100%, and so take every stake, payment or prize as it leaves a vault',
|
|
1062
|
+
p.pausable && 'pause every transfer of it, and so block a repayment, a refund or an unstake',
|
|
1031
1063
|
p.defaultFrozen && 'start every new account frozen',
|
|
1032
1064
|
!p.defaultFrozen && p.defaultState && p.freeze && 'switch every new account to start frozen',
|
|
1033
1065
|
p.mintAuthority === 'someone else' && 'mint more of it whenever its owner wants',
|
|
@@ -1036,8 +1068,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1036
1068
|
warn(
|
|
1037
1069
|
'W-MINT-POWERS',
|
|
1038
1070
|
`${where}.powers`,
|
|
1039
|
-
`Whoever controls the mint of “${t.key}” can ${powers.join('; ')}. The program
|
|
1040
|
-
'Use a mint without those powers, or say so where players buy.',
|
|
1071
|
+
`Whoever controls the mint of “${t.key}” can ${powers.join('; ')}. The program takes any token: a farm, a vest, a box or a prize in it is only as safe as the token, and players should know.`,
|
|
1072
|
+
'Use a mint without those powers, or say so where players buy, stake or are paid.',
|
|
1041
1073
|
);
|
|
1042
1074
|
if (source === 'existing' && !t.powers)
|
|
1043
1075
|
warn(
|
|
@@ -1209,7 +1241,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1209
1241
|
);
|
|
1210
1242
|
const p = poolKey ? pool(poolKey, `${at}.from`) : null;
|
|
1211
1243
|
const t = minted ? token(line.currency, `${at}.currency`) : p ? m.tokens.get(p.currency) : null;
|
|
1212
|
-
|
|
1244
|
+
// A lottery's place passes over a prize it can't mint: taken, and said. Elsewhere the claim or craft fails.
|
|
1245
|
+
if (minted && t && !t.gameMints && code === 'E-LOTTERY')
|
|
1246
|
+
warn(
|
|
1247
|
+
'W-MINT-NOT-OURS',
|
|
1248
|
+
`${at}.currency`,
|
|
1249
|
+
`${what} mints ${t.symbol}, which the project cannot mint: until the project is the mint’s authority, a pull by anyone but the place’s winner or the project’s keys fails, and the winner or the keys can pass it over for nothing.`,
|
|
1250
|
+
'Make the project the mint’s authority before the draw, pay it from a pool ("from": "pool:<key>"), or use a token with source "project".',
|
|
1251
|
+
);
|
|
1252
|
+
else if (minted && t && !t.gameMints)
|
|
1213
1253
|
refuse(
|
|
1214
1254
|
'E-MINT-NOT-OURS',
|
|
1215
1255
|
`${at}.currency`,
|
|
@@ -1223,8 +1263,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1223
1263
|
`${what} gives ${m.tokens.get(line.currency)?.symbol ?? line.currency} from “${p.key}”, which holds ${m.tokens.get(p.currency)?.symbol}.`,
|
|
1224
1264
|
`Leave "currency" out: a pool’s prize is in what it holds.`,
|
|
1225
1265
|
);
|
|
1266
|
+
// A prize of 0 tokens is given with nothing moved, on every route.
|
|
1226
1267
|
if (t && units(line.amount, t, `${at}.amount`, `What ${what} gives`) === 0n)
|
|
1227
|
-
|
|
1268
|
+
warn(
|
|
1269
|
+
'W-ZERO',
|
|
1270
|
+
`${at}.amount`,
|
|
1271
|
+
`${what} gives 0 ${t.symbol ?? 'tokens'}: the prize is given, and its winner gets nothing.`,
|
|
1272
|
+
'Give it an amount above 0, or leave the prize out.',
|
|
1273
|
+
);
|
|
1228
1274
|
} else if (line.gives === 'nft' && line.held) {
|
|
1229
1275
|
// An NFT a lottery's place holds in the lottery's own stash: a lottery's place only (prize.rs Prize::Held).
|
|
1230
1276
|
if (code !== 'E-LOTTERY')
|
|
@@ -1297,31 +1343,34 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1297
1343
|
return line;
|
|
1298
1344
|
};
|
|
1299
1345
|
/**
|
|
1300
|
-
* A
|
|
1301
|
-
*
|
|
1346
|
+
* A token a player can hold up a payment in: its new accounts start frozen or can be switched to, its
|
|
1347
|
+
* holders can turn plain transfers away (confidential transfers), or it can't be transferred at all. The program
|
|
1348
|
+
* takes it in an auction, an offer, a loan, a lottery and a contest; this says what a player can then do, by `kind`.
|
|
1349
|
+
* Only when the spec says so (`powers`).
|
|
1302
1350
|
*/
|
|
1303
|
-
const
|
|
1304
|
-
if (t?.powers?.confidentialTransfers)
|
|
1305
|
-
refuse(
|
|
1306
|
-
code,
|
|
1307
|
-
where,
|
|
1308
|
-
`${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).`,
|
|
1309
|
-
'Use a mint without confidential transfers.',
|
|
1310
|
-
);
|
|
1311
|
-
};
|
|
1312
|
-
/**
|
|
1313
|
-
* A mint whose new accounts start frozen, or can be made to (a default state and a freeze authority, which can switch
|
|
1314
|
-
* it to frozen): a lottery, a contest, an auction and a sealed schedule refuse it as they open, since each reads the
|
|
1315
|
-
* setting once, and a payout or a refund opened into a frozen account could never land. Only when the spec says so.
|
|
1316
|
-
*/
|
|
1317
|
-
const startsFrozen = (t, where, what, code, error) => {
|
|
1351
|
+
const holdsUp = (t, where, what, kind) => {
|
|
1318
1352
|
const p = t?.powers ?? {};
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1353
|
+
const frozen = p.defaultFrozen || (p.defaultState && p.freeze);
|
|
1354
|
+
if (!frozen && !p.confidentialTransfers && !p.nonTransferable) return;
|
|
1355
|
+
const how = [
|
|
1356
|
+
p.defaultFrozen && 'its new accounts start frozen',
|
|
1357
|
+
!p.defaultFrozen && frozen && 'its new accounts can be switched to start frozen',
|
|
1358
|
+
p.confidentialTransfers && 'its holders can turn plain transfers away (confidential transfers)',
|
|
1359
|
+
p.nonTransferable && 'it cannot be transferred at all',
|
|
1360
|
+
].filter(Boolean);
|
|
1361
|
+
const does = {
|
|
1362
|
+
auction:
|
|
1363
|
+
'the highest bidder can close their own account of it so that every higher bid fails (a topped bid is sent back to that account), and win at their price. Tell the seller and every bidder.',
|
|
1364
|
+
lottery:
|
|
1365
|
+
'a player who turns plain transfers into their own account off (or closes it) holds up only their own part, but the round cannot close meanwhile and its rent waits: keep spare SOL in the lottery ("roundRent") so no buyer fronts it. A pot or pool in a token that could not reach the round gives that round nothing.',
|
|
1366
|
+
contest:
|
|
1367
|
+
'a winner who closes their own account of it holds up their own claim and the contest’s close; a split prize minted in a token that cannot be transferred must be given up by each winner.',
|
|
1368
|
+
}[kind];
|
|
1369
|
+
warn(
|
|
1370
|
+
'W-HOLD-UP',
|
|
1322
1371
|
where,
|
|
1323
|
-
`${what} is in ${t.symbol},
|
|
1324
|
-
'Use a
|
|
1372
|
+
`${what} is in ${t.symbol ?? t.key}, and ${how.join('; ')}: ${p.nonTransferable ? 'nothing can be paid in, so nobody can take part. ' : ''}${does}`,
|
|
1373
|
+
'Use a token with none of those powers, or tell players before they take part.',
|
|
1325
1374
|
);
|
|
1326
1375
|
};
|
|
1327
1376
|
/** Whether `address` is the game's own vault for `t` (where open orders, prizes set aside and bids wait), when both are known. */
|
|
@@ -1363,6 +1412,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1363
1412
|
};
|
|
1364
1413
|
/** Whether the words on a Token Metadata print were said (once a spec). */
|
|
1365
1414
|
let printsSaid = false;
|
|
1415
|
+
/** Whether the words on NFTs of a collection the project doesn't control were said (once a spec). */
|
|
1416
|
+
let outsideSaid = false;
|
|
1366
1417
|
/** A rule on what an NFT says (rules.rs): an item of the game's must be a collectible; or a collection is named. */
|
|
1367
1418
|
const rule = (r, where, what) => {
|
|
1368
1419
|
if (!r) return;
|
|
@@ -1377,6 +1428,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1377
1428
|
'Nothing to change: tell holders of prints that only the original NFTs count.',
|
|
1378
1429
|
);
|
|
1379
1430
|
}
|
|
1431
|
+
// An NFT whose creator kept a permanent delegate can be pulled back out of what the project holds.
|
|
1432
|
+
if (r.collection && isAddress(r.collection) && !m.collections.includes(r.collection) && !outsideSaid) {
|
|
1433
|
+
outsideSaid = true;
|
|
1434
|
+
warn(
|
|
1435
|
+
'W-OUTSIDE-NFT',
|
|
1436
|
+
`${where}.collection`,
|
|
1437
|
+
`${what} takes NFTs of ${r.collection}, a collection the project does not control. The creator of an NFT with a permanent delegate can pull it back out of a hold, a stake, a loan or a listing, and a record it leaves behind can later take the next owner’s deposit of the same NFT. This holds for every rule on such a collection in the spec.`,
|
|
1438
|
+
'Before a player deposits one, the app looks for this project’s records that name the NFT (a listing, a hold) and warns when one is left from an earlier owner; prefer collections without a permanent delegate.',
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1380
1441
|
// A gate may list wallets instead (rules.rs Gate::Wallets): a duel's two players, a tournament's entrants.
|
|
1381
1442
|
// Or a list of any size (rules.rs Gate::List): its root on chain, each player's proof passed as accounts.
|
|
1382
1443
|
if (r.list !== undefined) {
|
|
@@ -1492,13 +1553,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1492
1553
|
`${what} names "items" and an item, a collection or an attribute: "items" alone means any of those items.`,
|
|
1493
1554
|
'Keep "items", or the other form.',
|
|
1494
1555
|
);
|
|
1495
|
-
if (!Array.isArray(r.items) || !r.items.length
|
|
1496
|
-
refuse(
|
|
1497
|
-
'E-RULE',
|
|
1498
|
-
`${where}.items`,
|
|
1499
|
-
`${what} names ${r.items?.length ?? 0} items: 1 to 16.`,
|
|
1500
|
-
'Name 1 to 16 items.',
|
|
1501
|
-
);
|
|
1556
|
+
if (!Array.isArray(r.items) || !r.items.length)
|
|
1557
|
+
refuse('E-RULE', `${where}.items`, `${what} names no items.`, 'Name at least one item.');
|
|
1502
1558
|
for (const [i, k] of (r.items ?? []).entries()) {
|
|
1503
1559
|
const it = item(k, `${where}.items[${i}]`);
|
|
1504
1560
|
if (it && !isCollectible(it))
|
|
@@ -1511,13 +1567,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1511
1567
|
}
|
|
1512
1568
|
return;
|
|
1513
1569
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
`${what} lists ${r.oneOf?.length ?? 0} texts in "oneOf": 1 to 16.`,
|
|
1519
|
-
'List 1 to 16 texts.',
|
|
1520
|
-
);
|
|
1570
|
+
// "One of" takes as many texts as the record that holds the rule fits (rules.rs): bounded where it is read, a
|
|
1571
|
+
// lottery's NFT ticket and a box's gate and odds.
|
|
1572
|
+
if (r.oneOf !== undefined && (!Array.isArray(r.oneOf) || !r.oneOf.length))
|
|
1573
|
+
refuse('E-RULE', `${where}.oneOf`, `${what} lists no texts in "oneOf".`, 'List at least one text.');
|
|
1521
1574
|
if (r.oneOf !== undefined && (r.equals !== undefined || r.atLeast !== undefined || r.time !== undefined))
|
|
1522
1575
|
refuse(
|
|
1523
1576
|
'E-RULE',
|
|
@@ -1651,6 +1704,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1651
1704
|
* treasury wallet's account of `tok`, whatever token it is. `chosen`: the buyer may name one share ("chosen",
|
|
1652
1705
|
* "chosen:<item>"): a box's or a lottery's split; 'wallet': only "chosen", a wallet (a payment's split).
|
|
1653
1706
|
*/
|
|
1707
|
+
let holderSaid = false;
|
|
1654
1708
|
const split = (list, tok, where, { what, chosen = false, single = false }) => {
|
|
1655
1709
|
// Where share `i` is: one destination given alone (`single`) is the field itself.
|
|
1656
1710
|
const to = i => (single ? where : `${where}[${i}].to`);
|
|
@@ -1718,6 +1772,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1718
1772
|
`Send it to a farm whose rewards are ${tok.symbol}.`,
|
|
1719
1773
|
);
|
|
1720
1774
|
}
|
|
1775
|
+
// A holder's share names the holder when it is paid.
|
|
1776
|
+
if ((s.kind === 'holder' || (s.kind === 'chosen' && s.key)) && !holderSaid) {
|
|
1777
|
+
holderSaid = true;
|
|
1778
|
+
warn(
|
|
1779
|
+
'W-HOLDER-SHARE',
|
|
1780
|
+
to(i),
|
|
1781
|
+
`${what} pays a share to whoever holds a collectible: the settle names the holder at that moment, and if the holder moves the collectible first, the settle fails and is sent again with the holder read afresh.`,
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1721
1784
|
if (['account', 'holder', 'wallet'].includes(s.kind) && !isAddress(s.key))
|
|
1722
1785
|
refuse(
|
|
1723
1786
|
'E-REF',
|
|
@@ -1850,7 +1913,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1850
1913
|
warn(
|
|
1851
1914
|
'W-COLLECTIONS',
|
|
1852
1915
|
'economy.collections',
|
|
1853
|
-
`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
|
|
1916
|
+
`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 takes one whose update authority is your wallet, or a Core collection where the project is an update delegate (BadCollection otherwise). An item minted into one needs the project as an update delegate of it, which you add to the collection yourself.`,
|
|
1917
|
+
);
|
|
1918
|
+
// A Core collection where the project is only an update delegate: its owner and every other delegate can
|
|
1919
|
+
// write to it, and what they make reads as the project's own.
|
|
1920
|
+
if (m.collections.length)
|
|
1921
|
+
warn(
|
|
1922
|
+
'W-COLLECTION-PARTNER',
|
|
1923
|
+
'economy.collections',
|
|
1924
|
+
'If the project is only an update delegate of a collection it names (someone else owns it), you trust that partner: every item they mint into it counts as your own, and they can take your access away at any time. Anyone who can write to that collection, its owner and every other update delegate they named, can make an NFT that reads as any of the project’s items, in any of its collections, by minting one there with that "item_id": it passes the project’s gates, boosts and odds for that item and takes its missions’ prizes. Two projects that name one shared collection read each other’s items of the same number as their own. Once the access is taken away, purchases of items in it are refused, a won item can only be settled by giving it up, a staked NFT in a farm that writes on it leaves only by forfeit, and a box that holds a player’s NFT to change its numbers cannot settle until the access is back.',
|
|
1925
|
+
'Name only a collection whose every writer you trust, and never one another loot project also names.',
|
|
1854
1926
|
);
|
|
1855
1927
|
const given = new Set();
|
|
1856
1928
|
for (const b of m.allBoxes)
|
|
@@ -1912,6 +1984,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
1912
1984
|
'Shorten it.',
|
|
1913
1985
|
);
|
|
1914
1986
|
});
|
|
1987
|
+
// A row of weight 0 or stock 0 is never drawn; a table with none that can be is drawn from as an empty one.
|
|
1988
|
+
const dead = (t.rows ?? []).map((r, i) => [r, i]).filter(([r]) => r.weight === 0 || r.stock === 0);
|
|
1989
|
+
if (dead.length)
|
|
1990
|
+
warn(
|
|
1991
|
+
'W-ZERO',
|
|
1992
|
+
`${where}.rows`,
|
|
1993
|
+
`${dead.length === (t.rows ?? []).length ? 'Every row' : `Row${dead.length === 1 ? '' : 's'} ${dead.map(([, i]) => i).join(', ')}`} of the table “${t.key}” ${dead.length === 1 ? 'has' : 'have'} a weight or a stock of 0: never drawn${dead.length === (t.rows ?? []).length ? ', so a draw from it finds nothing' : ''}.`,
|
|
1994
|
+
'Give each row a weight and a stock above 0, or leave the row out.',
|
|
1995
|
+
);
|
|
1915
1996
|
if (!m.items.some(it => (it.drawn ?? []).some(d => d.table === t.key)))
|
|
1916
1997
|
warn(
|
|
1917
1998
|
'W-TABLE',
|
|
@@ -2141,6 +2222,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2141
2222
|
}
|
|
2142
2223
|
const users = stashUsers.get(s.key);
|
|
2143
2224
|
const named = directs.filter(p => p.prize.stash === s.key);
|
|
2225
|
+
// NFTs of weight 0: never drawn, given only by name.
|
|
2226
|
+
const light = stashNfts(s).filter(n => n.weight === 0);
|
|
2227
|
+
if (light.length)
|
|
2228
|
+
warn(
|
|
2229
|
+
'W-STASH',
|
|
2230
|
+
`${where}.nfts`,
|
|
2231
|
+
`${label(s)} holds ${light.length} NFT${light.length === 1 ? '' : 's'} of weight 0: never drawn, and given only by name. A raffle counts them among the stash’s NFTs, so a raffle with more places than NFTs that weigh something leaves the extra places with nothing${light.length === stashNfts(s).length ? '; with none that weighs, a draw from it finds nothing, and a box that stops selling when an outcome is out is not sold' : '; a box that stops selling when out may land on the stash and find nothing to draw'}.`,
|
|
2232
|
+
'Give the NFTs to draw a weight above 0; keep weight 0 for NFTs a prize names.',
|
|
2233
|
+
);
|
|
2144
2234
|
// A known limit of the program: a named NFT is found by reading the stash's list from its start, about 52
|
|
2145
2235
|
// 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
|
|
2146
2236
|
// stash that players fill (a box's fate, a recipe's trade-in, a lottery's NFT ticket) can grow past what fits.
|
|
@@ -2162,7 +2252,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2162
2252
|
}
|
|
2163
2253
|
if (!(s.nfts ?? []).length && !intoStash.has(s.key)) {
|
|
2164
2254
|
// A box that stops selling when an outcome runs out (the default) isn't sold at all while it is empty.
|
|
2165
|
-
const stopped = users.filter(b => b.active !== false && b
|
|
2255
|
+
const stopped = users.filter(b => b.active !== false && stopsSales(b) && !boxBuys(b, m));
|
|
2166
2256
|
warn(
|
|
2167
2257
|
'W-STASH',
|
|
2168
2258
|
`${where}.nfts`,
|
|
@@ -2222,6 +2312,82 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2222
2312
|
}
|
|
2223
2313
|
|
|
2224
2314
|
// ---- boxes (catalog.rs, order.rs)
|
|
2315
|
+
/**
|
|
2316
|
+
* What one answer of the oracle's holds: the program refuses a box when it is written (PackTooLarge) when one
|
|
2317
|
+
* answer can't hold a draw of it, a box of one draw included. Each measured number (`ANSWER_HOLDS`) is the most of
|
|
2318
|
+
* one thing in a box otherwise small: past one, the box is refused here; a box that mixes several near their numbers
|
|
2319
|
+
* is warned of, since together they fit less.
|
|
2320
|
+
*/
|
|
2321
|
+
const answerHolds = (b, where) => {
|
|
2322
|
+
const outs = b.outcomes ?? [];
|
|
2323
|
+
const texts = r => (r?.oneOf ?? []).length + (r?.items ?? []).length;
|
|
2324
|
+
const parts = outs.map(outcomeParts);
|
|
2325
|
+
const tokenOutcomes = parts.filter(p => p.some(x => x.gives === 'pay' || x.gives === 'mint')).length;
|
|
2326
|
+
const itemOutcomes = parts.filter(p => p.some(x => x.gives === 'item')).length;
|
|
2327
|
+
const gateTexts = texts(b.gate);
|
|
2328
|
+
const oddsTexts = (b.odds ?? []).reduce((n, o) => n + texts(o.when), 0);
|
|
2329
|
+
const over = [
|
|
2330
|
+
[outs.length, ANSWER_HOLDS.entriesOfNothing, `${outs.length} outcomes`, `${where}.outcomes`],
|
|
2331
|
+
[(b.slots ?? []).length, ANSWER_HOLDS.slots, `${(b.slots ?? []).length} slots`, `${where}.slots`],
|
|
2332
|
+
[(b.odds ?? []).length, ANSWER_HOLDS.odds, `${(b.odds ?? []).length} odds rules`, `${where}.odds`],
|
|
2333
|
+
[gateTexts, ANSWER_HOLDS.gateTexts, `${gateTexts} texts in its gate`, `${where}.gate`],
|
|
2334
|
+
...parts.flatMap((p, i) => [
|
|
2335
|
+
[
|
|
2336
|
+
p.filter(x => x.gives === 'pay' || x.gives === 'mint').length,
|
|
2337
|
+
ANSWER_HOLDS.entryTokens,
|
|
2338
|
+
`${p.filter(x => x.gives === 'pay' || x.gives === 'mint').length} token prizes in outcome ${i}`,
|
|
2339
|
+
`${where}.outcomes[${i}]`,
|
|
2340
|
+
],
|
|
2341
|
+
[
|
|
2342
|
+
p.filter(x => x.gives === 'item').length,
|
|
2343
|
+
ANSWER_HOLDS.entryCollectibles,
|
|
2344
|
+
`${p.filter(x => x.gives === 'item').length} items in outcome ${i}`,
|
|
2345
|
+
`${where}.outcomes[${i}]`,
|
|
2346
|
+
],
|
|
2347
|
+
]),
|
|
2348
|
+
].filter(([n, most]) => n > most);
|
|
2349
|
+
for (const [, most, what, at] of over)
|
|
2350
|
+
refuse(
|
|
2351
|
+
'E-ODDS',
|
|
2352
|
+
at,
|
|
2353
|
+
`${label(b)} has ${what}: one answer of the oracle’s holds at most ${most} of that, as measured, and the program refuses a box when it is written that one answer can’t draw (PackTooLarge).`,
|
|
2354
|
+
'Put a long weighted list in a table (an item that draws a trait from it) or a stash, drawn by one outcome; give fewer prizes in one outcome; keep a gate’s "oneOf" short.',
|
|
2355
|
+
);
|
|
2356
|
+
if (over.length) return;
|
|
2357
|
+
// How much of one answer the box takes, each part at its measured number: a guide, not the program's count.
|
|
2358
|
+
const used =
|
|
2359
|
+
(outs.length - tokenOutcomes - itemOutcomes) / ANSWER_HOLDS.entriesOfNothing +
|
|
2360
|
+
tokenOutcomes / ANSWER_HOLDS.entriesOfTokens +
|
|
2361
|
+
itemOutcomes / ANSWER_HOLDS.entriesOfItems +
|
|
2362
|
+
(b.slots ?? []).length / ANSWER_HOLDS.slots +
|
|
2363
|
+
(b.odds ?? []).length / ANSWER_HOLDS.odds +
|
|
2364
|
+
gateTexts / ANSWER_HOLDS.gateTexts;
|
|
2365
|
+
if (used > 0.75)
|
|
2366
|
+
warn(
|
|
2367
|
+
'W-ANSWER',
|
|
2368
|
+
where,
|
|
2369
|
+
`${label(b)} is long: by the measured numbers it takes about ${Math.round(used * 100)}% of what one answer of the oracle’s holds. One answer holds 128 outcomes that give nothing, 29 of token amounts or 18 each of its own item; 41 slots; 72 odds rules; 16 token prizes or 18 collectibles in one outcome; 340 texts in a box’s gate. A box that mixes them fits less, and one that does not fit is refused when it is written (PackTooLarge). A box written from a wallet carries about 960 bytes; a longer one needs a program call (a multisig).`,
|
|
2370
|
+
'Put a long weighted list in a table or a stash, drawn by one outcome.',
|
|
2371
|
+
);
|
|
2372
|
+
// An outcome of 15 to 18 collectibles fits an answer, but not a sale in one step.
|
|
2373
|
+
const many = parts.findIndex(p => p.filter(x => x.gives === 'item').length >= 15);
|
|
2374
|
+
if (many >= 0 && outs.length === 1 && boxBuys(b, m))
|
|
2375
|
+
warn(
|
|
2376
|
+
'W-ANSWER',
|
|
2377
|
+
`${where}.outcomes[${many}]`,
|
|
2378
|
+
`${label(b)} gives ${parts[many].filter(x => x.gives === 'item').length} collectibles in one outcome: a box of one outcome with 15 to 18 collectibles is too wide for a sale in one step and sells only through the oracle (an open, then a settle).`,
|
|
2379
|
+
'Fine as it is; or split the bundle in two boxes to sell each in one step.',
|
|
2380
|
+
);
|
|
2381
|
+
if (oddsTexts > 200)
|
|
2382
|
+
warn(
|
|
2383
|
+
'W-ANSWER',
|
|
2384
|
+
`${where}.odds`,
|
|
2385
|
+
`${label(b)}’s odds rules list ${oddsTexts} "one of" texts in all: the oracle’s answer reads them all, and past a few hundred it cannot land, so every purchase is refunded after 10 minutes.`,
|
|
2386
|
+
'Keep "one of" lists in odds rules short, or raise the odds by a number (an attribute "atLeast").',
|
|
2387
|
+
);
|
|
2388
|
+
};
|
|
2389
|
+
/** Every prize a route gives (a grant, a place, a mission, a recipe...), for what a box shares with them. */
|
|
2390
|
+
const routePrizes = prizes(m);
|
|
2225
2391
|
let chance = false;
|
|
2226
2392
|
let randomItems = false;
|
|
2227
2393
|
/** Per pool, the largest fixed prize a box pays from it, and every box that pays one: warned of once a pool, below. */
|
|
@@ -2232,43 +2398,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2232
2398
|
// rolls, or one outcome of items, with no outcome that burns the payment and no NFT to roll on (BadEntries).
|
|
2233
2399
|
const slots = b.slots ?? [];
|
|
2234
2400
|
const outs = b.outcomes ?? [];
|
|
2235
|
-
|
|
2236
|
-
outs.length,
|
|
2237
|
-
LIMITS.boxEntries,
|
|
2238
|
-
'E-ODDS',
|
|
2239
|
-
`${where}.outcomes`,
|
|
2240
|
-
`${label(b)} has ${outs.length} outcomes`,
|
|
2241
|
-
'BadEntries',
|
|
2242
|
-
'Use fewer outcomes, or roll on a table of rows ("drawn").',
|
|
2243
|
-
);
|
|
2244
|
-
tooMany(
|
|
2245
|
-
slots.length,
|
|
2246
|
-
LIMITS.boxSlots,
|
|
2247
|
-
'E-ODDS',
|
|
2248
|
-
`${where}.slots`,
|
|
2249
|
-
`${label(b)} has ${slots.length} slots`,
|
|
2250
|
-
'BadEntries',
|
|
2251
|
-
'Put alike draws into one slot with "count".',
|
|
2252
|
-
);
|
|
2253
|
-
tooMany(
|
|
2254
|
-
(b.odds ?? []).length,
|
|
2255
|
-
LIMITS.boxOdds,
|
|
2256
|
-
'E-ODDS',
|
|
2257
|
-
`${where}.odds`,
|
|
2258
|
-
`${label(b)} has ${(b.odds ?? []).length} odds that read an NFT`,
|
|
2259
|
-
'BadEntries',
|
|
2260
|
-
'Keep the ones that matter.',
|
|
2261
|
-
);
|
|
2262
|
-
const fated = outs.filter(o => (o.shifts ?? []).length || (o.then ?? b.then ?? 'back') !== 'back').length;
|
|
2263
|
-
tooMany(
|
|
2264
|
-
fated,
|
|
2265
|
-
LIMITS.boxFates,
|
|
2266
|
-
'E-ROLL',
|
|
2267
|
-
`${where}.outcomes`,
|
|
2268
|
-
`${fated} outcomes of ${label(b)} act on the NFT it rolls on`,
|
|
2269
|
-
'BadEntries',
|
|
2270
|
-
'Have fewer outcomes act on it.',
|
|
2271
|
-
);
|
|
2401
|
+
answerHolds(b, where);
|
|
2272
2402
|
for (const [i, o] of outs.entries()) {
|
|
2273
2403
|
const given = outcomeParts(o);
|
|
2274
2404
|
tooMany(
|
|
@@ -2280,6 +2410,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2280
2410
|
'BadEntries',
|
|
2281
2411
|
'Give fewer, or make it two outcomes.',
|
|
2282
2412
|
);
|
|
2413
|
+
// What a roll changes on the NFT it is on is written by the settle, with the NFT's own attributes.
|
|
2414
|
+
if ((o.shifts ?? []).length > 16)
|
|
2415
|
+
warn(
|
|
2416
|
+
'W-ANSWER',
|
|
2417
|
+
`${where}.outcomes[${i}].shifts`,
|
|
2418
|
+
`An outcome of ${label(b)} changes ${(o.shifts ?? []).length} numbers on the NFT it rolls on. The settle writes them with the NFT’s own attributes, and together they must stay under about 60, or the settle cannot land and a held NFT waits with the project.`,
|
|
2419
|
+
'Change fewer numbers in one outcome, and keep the NFTs it rolls on to few attributes.',
|
|
2420
|
+
);
|
|
2283
2421
|
// An outcome gives all it names or isn't given (order.rs): what it names is refused at the write when it can clash.
|
|
2284
2422
|
for (const [j, x] of given.entries()) {
|
|
2285
2423
|
const clash = given
|
|
@@ -2302,14 +2440,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2302
2440
|
warn(
|
|
2303
2441
|
'W-OUT-OF-STOCK',
|
|
2304
2442
|
`${where}.outcomes[${i}]`,
|
|
2305
|
-
`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
|
|
2443
|
+
`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${stopsSales(b) && 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'}.`,
|
|
2306
2444
|
'Give fewer, or raise its supply.',
|
|
2307
2445
|
);
|
|
2308
2446
|
}
|
|
2309
2447
|
}
|
|
2310
2448
|
// A box that stops selling once an outcome is out covers each purchase's draws, and a random draw of a stash could
|
|
2311
2449
|
// take one of its NFTs another outcome names: it can't list both (catalog.rs, BadEntries).
|
|
2312
|
-
if (b
|
|
2450
|
+
if (stopsSales(b)) {
|
|
2313
2451
|
// One outcome naming both is refused above already.
|
|
2314
2452
|
const parts = outs.map(outcomeParts);
|
|
2315
2453
|
const random = parts.flatMap((p, i) => p.filter(x => x.gives === 'stash').map(x => [i, x.stash]));
|
|
@@ -2325,7 +2463,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2325
2463
|
'E-ODDS',
|
|
2326
2464
|
`${where}.outcomes`,
|
|
2327
2465
|
`${label(b)} gives an NFT of the stash “${key}” at random and, in another outcome, one of its NFTs by name, and it stops selling once an outcome is out ("whenOut": "stopSales", the default): a random draw could take the named one, so nothing could cover it, and the program refuses the box (BadEntries).`,
|
|
2328
|
-
`Make it "whenOut": "drawLeft", or give the named NFT from a stash of its own.`,
|
|
2466
|
+
`Make it "whenOut": "drawLeft" or "giveNothing", or give the named NFT from a stash of its own.`,
|
|
2329
2467
|
);
|
|
2330
2468
|
}
|
|
2331
2469
|
// A fresh outcome's tables come with the answer while they fit what the box's own accounts leave, in outcome order
|
|
@@ -2799,11 +2937,12 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2799
2937
|
: refuse('E-ODDS', at, `An outcome of ${label(b)} pays from a pool but does not say which.`, 'Add "pool".');
|
|
2800
2938
|
if (p) {
|
|
2801
2939
|
const amount = units(o.amount, m.tokens.get(p.currency), `${at}.amount`, `${label(b)}’s prize`);
|
|
2940
|
+
// A prize of 0 tokens is given with nothing moved.
|
|
2802
2941
|
if (amount === 0n)
|
|
2803
|
-
|
|
2804
|
-
'
|
|
2942
|
+
warn(
|
|
2943
|
+
'W-ZERO',
|
|
2805
2944
|
`${at}.amount`,
|
|
2806
|
-
`An outcome of ${label(b)} pays nothing
|
|
2945
|
+
`An outcome of ${label(b)} pays 0: it is drawn and given, and its winner gets nothing.`,
|
|
2807
2946
|
'Give it an amount above 0, or make it "nothing".',
|
|
2808
2947
|
);
|
|
2809
2948
|
// In base units, exact (null when it is not an amount, refused above).
|
|
@@ -2909,6 +3048,54 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2909
3048
|
`${label(b)} sells in one step, all of it or nothing, so "whenOut" changes nothing.`,
|
|
2910
3049
|
'Leave "whenOut" out.',
|
|
2911
3050
|
);
|
|
3051
|
+
// What a draw on an outcome that is out does, by the setting the project chose, and who can make it happen by
|
|
3052
|
+
// emptying a source this box shares with another route.
|
|
3053
|
+
const runsDry = o =>
|
|
3054
|
+
outcomeParts(o).some(
|
|
3055
|
+
x =>
|
|
3056
|
+
x.gives === 'pay' ||
|
|
3057
|
+
x.gives === 'stash' ||
|
|
3058
|
+
x.gives === 'nft' ||
|
|
3059
|
+
(x.gives === 'item' && Number(m.item.get(x.item)?.supply ?? 0) > 0),
|
|
3060
|
+
);
|
|
3061
|
+
const rolledHere = !boxBuys(b, m) || rolls.some(r => r.box === b.key);
|
|
3062
|
+
if (b.whenOut === 'giveNothing' && rolledHere)
|
|
3063
|
+
warn(
|
|
3064
|
+
'W-WHEN-OUT',
|
|
3065
|
+
`${where}.whenOut`,
|
|
3066
|
+
`${label(b)} gives nothing for a draw on an outcome that is out ("whenOut": "giveNothing"): a paid draw is not refunded, a free roll is over, and the box keeps selling. Anyone who empties what an outcome gives (a pool, an item’s units, a stash), by this box or another route that takes from it, makes its rolls miss.`,
|
|
3067
|
+
'Tell players a draw can miss, keep each outcome in stock, or keep "stopSales" (a paid miss refunded) or "drawLeft" (a draw among what is left).',
|
|
3068
|
+
);
|
|
3069
|
+
const sharedSources = outs
|
|
3070
|
+
.flatMap(outcomeParts)
|
|
3071
|
+
.map(x =>
|
|
3072
|
+
x.gives === 'pay'
|
|
3073
|
+
? ['pool', x.pool]
|
|
3074
|
+
: x.gives === 'stash' || x.gives === 'nft'
|
|
3075
|
+
? ['stash', x.stash]
|
|
3076
|
+
: x.gives === 'item'
|
|
3077
|
+
? ['item', x.item]
|
|
3078
|
+
: null,
|
|
3079
|
+
)
|
|
3080
|
+
.filter(Boolean)
|
|
3081
|
+
.filter(([kind, key]) =>
|
|
3082
|
+
kind === 'pool'
|
|
3083
|
+
? takers.some(t => t.pool === key && t.by !== `box:${b.key}`)
|
|
3084
|
+
: kind === 'stash'
|
|
3085
|
+
? routePrizes.some(p => p.prize.stash === key) ||
|
|
3086
|
+
m.allBoxes.some(x => x !== b && (x.outcomes ?? []).flatMap(outcomeParts).some(y => y.stash === key))
|
|
3087
|
+
: Number(m.item.get(key)?.supply ?? 0) > 0 &&
|
|
3088
|
+
(Number(m.item.get(key)?.price ?? 0) > 0 ||
|
|
3089
|
+
routePrizes.some(p => p.prize.item === key) ||
|
|
3090
|
+
m.allBoxes.some(x => x !== b && (x.outcomes ?? []).flatMap(outcomeParts).some(y => y.item === key))),
|
|
3091
|
+
);
|
|
3092
|
+
if (sharedSources.length && rolledHere && outs.some(runsDry) && b.active !== false)
|
|
3093
|
+
warn(
|
|
3094
|
+
'W-SHARED-SOURCE',
|
|
3095
|
+
`${where}.outcomes`,
|
|
3096
|
+
`${label(b)} draws from ${[...new Set(sharedSources.map(([kind, key]) => `the ${kind} “${key}”`))].join(', ')}, which another route also takes from. ${stopsSales(b) ? 'A buyer can open the box, empty that source by the other route before the answers land, and be refunded every miss' : b.whenOut === 'giveNothing' ? 'Anyone emptying that source makes this box’s rolls miss, and a miss gives nothing' : 'Anyone emptying that source moves this box’s odds to what is left'}.`,
|
|
3097
|
+
'Give the box sources of its own; cap a fresh item’s supply at what its table holds.',
|
|
3098
|
+
);
|
|
2912
3099
|
if (b.whenOut === 'drawLeft' && boxRolls(b, m)) {
|
|
2913
3100
|
// The rarest outcome that gives something, against the likeliest outcome that can run out.
|
|
2914
3101
|
const w = weights(b);
|
|
@@ -2950,7 +3137,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
2950
3137
|
const others = [...boxes].filter(x => x !== box).map(label);
|
|
2951
3138
|
// A box that stops selling when an outcome is out (the default) needs the prize for this purchase's draws and every
|
|
2952
3139
|
// draw still waiting for the oracle before it sells at all.
|
|
2953
|
-
const stopped = [...boxes].filter(x => x.active !== false && x
|
|
3140
|
+
const stopped = [...boxes].filter(x => x.active !== false && stopsSales(x) && boxRolls(x, m));
|
|
2954
3141
|
const stops = stopped.length
|
|
2955
3142
|
? ` ${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.`
|
|
2956
3143
|
: '';
|
|
@@ -3017,22 +3204,32 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3017
3204
|
};
|
|
3018
3205
|
}
|
|
3019
3206
|
})();
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
['
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3207
|
+
// A recipe takes any number of lines; what one craft can carry is measured: past it, the recipe is written
|
|
3208
|
+
// and never crafts.
|
|
3209
|
+
{
|
|
3210
|
+
const pays = ins.filter(l => l.takes === 'pay');
|
|
3211
|
+
const coins = new Set(pays.map(l => l.currency ?? m.currencyKey)).size;
|
|
3212
|
+
const pooled = (r.out ?? []).filter(l => l.gives === 'tokens' && prizePool(l)).length;
|
|
3213
|
+
const raised = lines.raises.length + lines.needs.length;
|
|
3214
|
+
const past = [
|
|
3215
|
+
coins > 1 &&
|
|
3216
|
+
pays.length > ANSWER_HOLDS.recipeCoins &&
|
|
3217
|
+
`${pays.length} payments in ${coins} coins (at most ${ANSWER_HOLDS.recipeCoins})`,
|
|
3218
|
+
coins <= 1 &&
|
|
3219
|
+
pays.length > ANSWER_HOLDS.recipePayments &&
|
|
3220
|
+
`${pays.length} payments (at most ${ANSWER_HOLDS.recipePayments} in one coin)`,
|
|
3221
|
+
pooled > ANSWER_HOLDS.recipePoolOutputs &&
|
|
3222
|
+
`${pooled} outputs from a pool (at most ${ANSWER_HOLDS.recipePoolOutputs})`,
|
|
3223
|
+
raised > ANSWER_HOLDS.recipeRaises && `${raised} raises and needs (at most ${ANSWER_HOLDS.recipeRaises})`,
|
|
3224
|
+
].filter(Boolean);
|
|
3225
|
+
if (past.length)
|
|
3226
|
+
warn(
|
|
3227
|
+
'W-ANSWER',
|
|
3228
|
+
where,
|
|
3229
|
+
`${label(r)} has ${past.join(', ')}: the program writes it, but one craft can’t carry that much, so it never crafts.`,
|
|
3230
|
+
'Split it into two recipes.',
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3036
3233
|
const outs = r.out ?? [];
|
|
3037
3234
|
if (!ins.length && !outs.length)
|
|
3038
3235
|
warn(
|
|
@@ -3226,13 +3423,28 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3226
3423
|
const places = ins.flatMap(l =>
|
|
3227
3424
|
l.takes === 'burn' ? Array(l.count ?? 1).fill('burn') : l.takes === 'pay' ? [] : [l.takes],
|
|
3228
3425
|
);
|
|
3426
|
+
// Each roll is its own order, at the craft's nonce plus its count (craft.rs); its receipts must fit the log.
|
|
3229
3427
|
const rolls = outs.filter(l => l.gives === 'roll');
|
|
3230
|
-
if (rolls.length >
|
|
3231
|
-
|
|
3232
|
-
'
|
|
3428
|
+
if (rolls.length > ANSWER_HOLDS.recipeRolls)
|
|
3429
|
+
warn(
|
|
3430
|
+
'W-ANSWER',
|
|
3233
3431
|
`${where}.out`,
|
|
3234
|
-
`${label(r)}
|
|
3235
|
-
|
|
3432
|
+
`${label(r)} gives ${rolls.length} rolls: past ${ANSWER_HOLDS.recipeRolls}, a craft’s receipts run past what a transaction’s log holds (10,000 bytes), and its Crafted receipt is cut, so an indexer or a results page can’t read what it made.`,
|
|
3433
|
+
`Give at most ${ANSWER_HOLDS.recipeRolls} rolls a craft, or roll a box that gives a pack.`,
|
|
3434
|
+
);
|
|
3435
|
+
// Two rolls on one NFT whose box holds it while it rolls: the craft fails whole.
|
|
3436
|
+
const held = rolls.filter(
|
|
3437
|
+
l =>
|
|
3438
|
+
l.on !== undefined &&
|
|
3439
|
+
(m.box.get(l.box)?.outcomes ?? []).some(o => (o.shifts ?? []).length || (o.then && o.then !== 'back')),
|
|
3440
|
+
);
|
|
3441
|
+
const twice = held.find((l, i) => held.slice(0, i).some(x => x.on === l.on));
|
|
3442
|
+
if (twice)
|
|
3443
|
+
warn(
|
|
3444
|
+
'W-RECIPE',
|
|
3445
|
+
`${where}.out`,
|
|
3446
|
+
`${label(r)} gives two rolls on collectible ${twice.on}, and a box that holds the NFT while it rolls: the second can’t take the NFT the first holds, so every craft of it fails whole.`,
|
|
3447
|
+
'Give one roll on each collectible.',
|
|
3236
3448
|
);
|
|
3237
3449
|
for (const [i, line] of outs.entries()) {
|
|
3238
3450
|
if (line.gives !== 'roll') continue;
|
|
@@ -3394,7 +3606,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3394
3606
|
if (fromNowSeconds(s.every, `${where}.every`) <= 0)
|
|
3395
3607
|
refuse('E-SCHEDULE', `${where}.every`, `${label(s)} never comes round (every 0).`, 'Give it a time like "1d".');
|
|
3396
3608
|
try {
|
|
3397
|
-
toTime(s.start, now);
|
|
3609
|
+
farTime(toTime(s.start, now), `${where}.start`, `${label(s)}’s start`, s.start, 'it runs nothing');
|
|
3610
|
+
if (!s.sealed)
|
|
3611
|
+
farTime(
|
|
3612
|
+
now + seconds(s.every, `${where}.every`),
|
|
3613
|
+
`${where}.every`,
|
|
3614
|
+
`${label(s)}’s next run`,
|
|
3615
|
+
s.every,
|
|
3616
|
+
'it runs nothing',
|
|
3617
|
+
);
|
|
3398
3618
|
} catch (err) {
|
|
3399
3619
|
refuse(err.code, `${where}.start`, err.message, err.fix);
|
|
3400
3620
|
}
|
|
@@ -3481,16 +3701,17 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3481
3701
|
'Leave "active" out.',
|
|
3482
3702
|
);
|
|
3483
3703
|
const d = destination(s.to ?? '');
|
|
3484
|
-
// A
|
|
3485
|
-
//
|
|
3704
|
+
// A payee nobody signs for (an address off the curve): a multisig's vault is one, and so is an address of this
|
|
3705
|
+
// program, where a record made later would make every run pass it over. The program refuses only an
|
|
3706
|
+
// account this program already owns (BadMove), which it checks when the schedule is written.
|
|
3486
3707
|
if (d.kind === 'wallet' && isAddress(d.key) && !isOnCurve(addressBytes(d.key)))
|
|
3487
|
-
|
|
3488
|
-
'
|
|
3708
|
+
warn(
|
|
3709
|
+
'W-SCHEDULE-PAYEE',
|
|
3489
3710
|
`${where}.to`,
|
|
3490
|
-
`${label(s)} is sealed and pays ${d.key},
|
|
3491
|
-
'
|
|
3711
|
+
`${label(s)} is sealed and pays ${d.key}, an address nobody holds a key of: a program's address. A multisig's vault is fine; an address of the loot program is not: a record made there later would make every run pass it over, and the vault would never empty. Nothing can change the payee once it is sealed.`,
|
|
3712
|
+
'Check the address is a wallet or a multisig vault you can spend from, before you sign.',
|
|
3492
3713
|
);
|
|
3493
|
-
// Its last run
|
|
3714
|
+
// Its last run: far off is taken, and warned of with its date.
|
|
3494
3715
|
if (s.maxRuns > 0) {
|
|
3495
3716
|
let first = now + seconds(s.every, `${where}.every`);
|
|
3496
3717
|
try {
|
|
@@ -3498,21 +3719,23 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3498
3719
|
} catch {
|
|
3499
3720
|
// A start that doesn't read is refused where the schedule's times are checked.
|
|
3500
3721
|
}
|
|
3501
|
-
|
|
3722
|
+
const last = first + seconds(s.every, `${where}.every`) * (s.maxRuns - 1);
|
|
3723
|
+
if (last > Number.MAX_SAFE_INTEGER / 1000)
|
|
3502
3724
|
refuse(
|
|
3503
3725
|
'E-SCHEDULE',
|
|
3504
3726
|
`${where}.maxRuns`,
|
|
3505
|
-
`${label(s)} is sealed and its last run would come
|
|
3727
|
+
`${label(s)} is sealed and its last run would come further off than a date holds (BadSchedule).`,
|
|
3506
3728
|
'Fewer runs, a shorter "every", or an earlier "start".',
|
|
3507
3729
|
);
|
|
3730
|
+
else
|
|
3731
|
+
farTime(
|
|
3732
|
+
last,
|
|
3733
|
+
`${where}.maxRuns`,
|
|
3734
|
+
`${label(s)}’s last run`,
|
|
3735
|
+
s.every,
|
|
3736
|
+
'the vault keeps what is left, and nobody can take it out early',
|
|
3737
|
+
);
|
|
3508
3738
|
}
|
|
3509
|
-
if (s.maxRuns > MAX_SEALED_RUNS)
|
|
3510
|
-
refuse(
|
|
3511
|
-
'E-SCHEDULE',
|
|
3512
|
-
`${where}.maxRuns`,
|
|
3513
|
-
`${label(s)} is sealed with ${s.maxRuns} runs; a sealed schedule runs at most ${MAX_SEALED_RUNS} times (BadSchedule).`,
|
|
3514
|
-
'Fewer runs, each larger.',
|
|
3515
|
-
);
|
|
3516
3739
|
// 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
|
|
3517
3740
|
// authority moves a pool; an account's holder may close it).
|
|
3518
3741
|
if (!['wallet', 'treasury', 'burn'].includes(d.kind) || !s.to)
|
|
@@ -3529,14 +3752,34 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3529
3752
|
`${label(s)} burns SOL, and wrapped SOL cannot be burned.`,
|
|
3530
3753
|
'Pay it somewhere instead.',
|
|
3531
3754
|
);
|
|
3532
|
-
|
|
3533
|
-
|
|
3755
|
+
// A vault that pays a wallet or the treasury moves its tokens by a transfer: a token that can't be transferred
|
|
3756
|
+
// would sit in it for good. The program takes it (only a burn of a token whose burn needs its issuer is refused).
|
|
3757
|
+
if (tok?.powers?.nonTransferable && d.kind !== 'burn')
|
|
3534
3758
|
refuse(
|
|
3535
3759
|
'E-SCHEDULE',
|
|
3536
3760
|
`${where}.currency`,
|
|
3537
|
-
`${label(s)} is sealed in ${tok.symbol},
|
|
3761
|
+
`${label(s)} is sealed in ${tok.symbol}, which cannot be transferred: no run could ever pay ${d.kind === 'treasury' ? 'the treasury' : 'the wallet'}, and the vault, which nobody can open, would keep it all for good.`,
|
|
3538
3762
|
'Seal it in another token.',
|
|
3539
3763
|
);
|
|
3764
|
+
// A vest is only as safe as its token.
|
|
3765
|
+
const p = tok?.powers ?? {};
|
|
3766
|
+
const powers = [
|
|
3767
|
+
p.permanentDelegate && 'a permanent delegate can take the tokens out of the vault',
|
|
3768
|
+
p.feeChangeable && 'its issuer can raise the transfer fee to 100% and take each run as it leaves',
|
|
3769
|
+
p.closeAuthority && 'its mint can be closed and made again with other powers',
|
|
3770
|
+
(p.freeze || p.defaultFrozen || (p.defaultState && p.freeze)) &&
|
|
3771
|
+
'a freeze can hold the vault or the payee’s account',
|
|
3772
|
+
p.pausable && 'a pause can hold every run',
|
|
3773
|
+
p.transferHook && 'its transfer hook can refuse a run',
|
|
3774
|
+
p.confidentialTransfers && 'a payee can turn plain transfers away',
|
|
3775
|
+
].filter(Boolean);
|
|
3776
|
+
if (powers.length)
|
|
3777
|
+
warn(
|
|
3778
|
+
'W-SCHEDULE',
|
|
3779
|
+
`${where}.currency`,
|
|
3780
|
+
`${label(s)} is sealed in ${tok.symbol}: a vest is only as safe as its token, and ${powers.join('; ')}.`,
|
|
3781
|
+
'Seal it in a token without those powers, or tell whoever it pays.',
|
|
3782
|
+
);
|
|
3540
3783
|
if (byShare && !(percent(s.percent, `${where}.percent`) > 0))
|
|
3541
3784
|
refuse('E-SCHEDULE', `${where}.percent`, `${label(s)} pays 0%.`, 'Give it a percent above 0.');
|
|
3542
3785
|
else if (!byShare && units(s.amount, tok, `${where}.amount`, `What ${label(s)} pays`) === 0n)
|
|
@@ -3667,11 +3910,8 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3667
3910
|
const where = `economy.lotteries.${l.key}`;
|
|
3668
3911
|
const potToken = token(l.currency, `${where}.currency`);
|
|
3669
3912
|
const ticket = l.ticketCurrency ? token(l.ticketCurrency, `${where}.ticketCurrency`) : potToken;
|
|
3670
|
-
|
|
3671
|
-
if (ticket !== potToken)
|
|
3672
|
-
startsFrozen(potToken, `${where}.currency`, label(l), 'E-LOTTERY', 'BadLottery');
|
|
3673
|
-
if (ticket !== potToken)
|
|
3674
|
-
startsFrozen(ticket, `${where}.ticketCurrency`, `${label(l)}’s ticket`, 'E-LOTTERY', 'BadLottery');
|
|
3913
|
+
holdsUp(potToken, `${where}.currency`, label(l), 'lottery');
|
|
3914
|
+
if (ticket !== potToken) holdsUp(ticket, `${where}.ticketCurrency`, `${label(l)}’s ticket`, 'lottery');
|
|
3675
3915
|
if (l.ticketCurrency && ticket?.wsol)
|
|
3676
3916
|
refuse(
|
|
3677
3917
|
'E-LOTTERY',
|
|
@@ -3717,12 +3957,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3717
3957
|
single: true,
|
|
3718
3958
|
});
|
|
3719
3959
|
}
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3960
|
+
// A no-loss lottery that pays from its own money: every ticket goes back, so its places take no money and give
|
|
3961
|
+
// only their prizes.
|
|
3962
|
+
if (l.noLoss && (l.places ?? [100]).some(p => placeOf(p).percent > 0))
|
|
3963
|
+
warn(
|
|
3964
|
+
'W-LOTTERY',
|
|
3965
|
+
`${where}.places`,
|
|
3966
|
+
`${label(l)} gives every ticket back and pays each round from that round's own ticket money ("rest"): there is no money left for its places, so a place's percent wins nothing from it, and only its prize is won.`,
|
|
3967
|
+
'Give the places prizes, or keep a pot ("pot": 0) that the owner or other sales fill.',
|
|
3726
3968
|
);
|
|
3727
3969
|
if (Number(l.seed ?? 0) > 0)
|
|
3728
3970
|
refuse(
|
|
@@ -3798,26 +4040,58 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3798
4040
|
`${label(l)} draws once (no "every"), and the draw is due at go-live.`,
|
|
3799
4041
|
'Give it a "firstDraw" (like "publish+1d"), or an "every" to draw again and again.',
|
|
3800
4042
|
);
|
|
3801
|
-
// A
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
'E-LOTTERY',
|
|
3805
|
-
`${where}.every`,
|
|
3806
|
-
`${label(l)} draws every ${l.every}: a draw more than 5 years off sells no ticket (SalesClosed).`,
|
|
3807
|
-
'Draw within 5 years.',
|
|
3808
|
-
);
|
|
4043
|
+
// A draw far off is taken, and warned of with its date: the tickets wait until then.
|
|
4044
|
+
const every = seconds(l.every, `${where}.every`);
|
|
4045
|
+
let firstDraw = now + every;
|
|
3809
4046
|
try {
|
|
3810
|
-
if (l.firstDraw
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
4047
|
+
if (l.firstDraw) firstDraw = toTime(l.firstDraw, now);
|
|
4048
|
+
farTime(
|
|
4049
|
+
firstDraw,
|
|
4050
|
+
l.firstDraw ? `${where}.firstDraw` : `${where}.every`,
|
|
4051
|
+
`${label(l)}’s first draw`,
|
|
4052
|
+
l.firstDraw ?? l.every,
|
|
4053
|
+
'its tickets and their money wait in it',
|
|
4054
|
+
);
|
|
3818
4055
|
} catch (err) {
|
|
3819
4056
|
refuse(err.code, `${where}.firstDraw`, err.message, err.fix);
|
|
3820
4057
|
}
|
|
4058
|
+
// Sales close this long before each draw (lottery.rs `closes_before`): never a whole rhythm or more before it.
|
|
4059
|
+
const closesBefore = seconds(l.closesBefore, `${where}.closesBefore`);
|
|
4060
|
+
if (every > 0 && closesBefore >= every)
|
|
4061
|
+
refuse(
|
|
4062
|
+
'E-LOTTERY',
|
|
4063
|
+
`${where}.closesBefore`,
|
|
4064
|
+
`${label(l)} draws every ${l.every} and closes its sales ${l.closesBefore} before each draw: sales would never open (BadLottery).`,
|
|
4065
|
+
`Close sales less than ${l.every} before the draw.`,
|
|
4066
|
+
);
|
|
4067
|
+
else if (closesBefore > 0 && firstDraw - closesBefore <= now)
|
|
4068
|
+
warn(
|
|
4069
|
+
'W-LOTTERY',
|
|
4070
|
+
`${where}.closesBefore`,
|
|
4071
|
+
`${label(l)} closes its sales ${l.closesBefore} before its first draw, which is sooner than that after go-live: its first round sells no ticket (SalesClosed).`,
|
|
4072
|
+
'Draw later, or close sales closer to the draw.',
|
|
4073
|
+
);
|
|
4074
|
+
// Tickets weighed by the time they are held (lottery.rs `time_weighted`): a no-loss lottery only (BadLottery).
|
|
4075
|
+
if (l.timeWeighted && !l.noLoss)
|
|
4076
|
+
refuse(
|
|
4077
|
+
'E-LOTTERY',
|
|
4078
|
+
`${where}.timeWeighted`,
|
|
4079
|
+
`${label(l)} weighs its tickets by the time they are held, but keeps its ticket money: only a lottery that gives every ticket back may weigh them by time (BadLottery).`,
|
|
4080
|
+
'Make it "noLoss": true, or leave "timeWeighted" out.',
|
|
4081
|
+
);
|
|
4082
|
+
else if (l.timeWeighted)
|
|
4083
|
+
warn(
|
|
4084
|
+
'W-LOTTERY-WEIGHT',
|
|
4085
|
+
`${where}.timeWeighted`,
|
|
4086
|
+
`${label(l)} weighs a purchase by its tickets times the seconds from it to the close of sales${closesBefore ? ` (${l.closesBefore} before each draw)` : ' (at the draw: set "closesBefore" to the close you mean)'}: a ticket’s chance grows with the time it is held. Tell every buyer, on the buy screen, what their purchase weighs now and that the same tickets bought later weigh less. ${Number(l.price ?? 0) > 0 || l.ticketNft ? '' : 'Tickets are free: give it a "perWallet" limit or a gate, as any free lottery wants. '}"winners", "minSold" and "perWallet" still count tickets.`,
|
|
4087
|
+
);
|
|
4088
|
+
else if (l.noLoss && !closesBefore)
|
|
4089
|
+
warn(
|
|
4090
|
+
'W-LOTTERY',
|
|
4091
|
+
`${where}.closesBefore`,
|
|
4092
|
+
`${label(l)} gives every ticket back and sells until the draw: tickets bought seconds before it have the same chance at the pot as tickets held all period, and their price comes back.`,
|
|
4093
|
+
'Set "closesBefore" (sales close that long before the draw), or "timeWeighted": true so a ticket counts for the time it is held.',
|
|
4094
|
+
);
|
|
3821
4095
|
// A place takes a share of the pot, a roll of a box for its winner, or both (lottery.rs Place).
|
|
3822
4096
|
const placed = (l.places ?? [100]).map(placeOf);
|
|
3823
4097
|
tooMany(
|
|
@@ -3843,13 +4117,21 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3843
4117
|
);
|
|
3844
4118
|
if (typeof raw === 'object' && raw?.prize)
|
|
3845
4119
|
prizeCheck(raw.prize, `${where}.places[${i}].prize`, `${label(l)}’s place ${i + 1}`, 'E-LOTTERY');
|
|
3846
|
-
// A run holds places alike: a named NFT
|
|
3847
|
-
if (p.times > 1 &&
|
|
4120
|
+
// A run holds places alike: a named NFT is one place's (a roll may repeat: each place its own roll).
|
|
4121
|
+
if (p.times > 1 && p.prize?.gives === 'nft')
|
|
3848
4122
|
refuse(
|
|
3849
4123
|
'E-LOTTERY',
|
|
3850
4124
|
`${where}.places[${i}].times`,
|
|
3851
|
-
`${label(l)}’s place ${i + 1} repeats ${p.times} times a
|
|
3852
|
-
'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).',
|
|
4125
|
+
`${label(l)}’s place ${i + 1} repeats ${p.times} times a named NFT, which one place gives (BadLottery).`,
|
|
4126
|
+
'Give it to one place, or make it a prize places can share alike (tokens, an item, a roll, an NFT drawn from the lottery’s stash).',
|
|
4127
|
+
);
|
|
4128
|
+
// What a place of tokens from a pool gets when the pool is short (lottery.rs `WhenShort`).
|
|
4129
|
+
if (raw?.whenShort !== undefined && !(p.prize?.gives === 'tokens' && prizePool(p.prize)))
|
|
4130
|
+
warn(
|
|
4131
|
+
'W-LOTTERY',
|
|
4132
|
+
`${where}.places[${i}].whenShort`,
|
|
4133
|
+
`${label(l)}’s place ${i + 1} says what it gets when its pool is short, but it is not paid in tokens from a pool: "whenShort" changes nothing there.`,
|
|
4134
|
+
'Leave "whenShort" out.',
|
|
3853
4135
|
);
|
|
3854
4136
|
// A run of places given one by one (anything but tokens: a collectible, a compressed NFT, a raffled NFT) holds at
|
|
3855
4137
|
// most MAX_RUN_PLACES places: a pull walks the run from its first place (BadLottery).
|
|
@@ -3916,15 +4198,62 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3916
4198
|
`${label(l)} gives the same NFT on two places: the second could never be given (BadLottery).`,
|
|
3917
4199
|
'Name each NFT once.',
|
|
3918
4200
|
);
|
|
3919
|
-
//
|
|
3920
|
-
|
|
3921
|
-
const draw = 6_000 + 250 * Math.ceil(rows / 4_096);
|
|
3922
|
-
if (raffled * draw + held.length * 15 * rows > 1_300_000)
|
|
4201
|
+
// The places from its own stash, raffled or named, are what its draw's answer loads (lottery.rs `MOST_OWN`).
|
|
4202
|
+
if (raffled + held.length > LIMITS.ownStashPlaces)
|
|
3923
4203
|
refuse(
|
|
3924
4204
|
'E-LOTTERY',
|
|
3925
4205
|
`${where}.places`,
|
|
3926
|
-
`${label(l)}
|
|
3927
|
-
|
|
4206
|
+
`${label(l)} has ${raffled + held.length} places from its own stash: a raffle has at most ${LIMITS.ownStashPlaces}, the most the oracle’s answer loads on the chain’s default memory (BadLottery).`,
|
|
4207
|
+
`Raffle at most ${LIMITS.ownStashPlaces} NFTs a lottery: run a second lottery for the rest.`,
|
|
4208
|
+
);
|
|
4209
|
+
// Every run paying from one pool says the same about a short pool (lottery.rs, BadLottery), and the project hears
|
|
4210
|
+
// what its setting does.
|
|
4211
|
+
const byPool = new Map();
|
|
4212
|
+
for (const [i, p] of placed.entries()) {
|
|
4213
|
+
const key = p.prize?.gives === 'tokens' ? prizePool(p.prize) : null;
|
|
4214
|
+
if (!key) continue;
|
|
4215
|
+
byPool.set(key, [...(byPool.get(key) ?? []), [i, p.whenShort ?? 'inOrder']]);
|
|
4216
|
+
}
|
|
4217
|
+
for (const [key, runs] of byPool) {
|
|
4218
|
+
const said = [...new Set(runs.map(([, w]) => w))];
|
|
4219
|
+
if (said.length > 1)
|
|
4220
|
+
refuse(
|
|
4221
|
+
'E-LOTTERY',
|
|
4222
|
+
`${where}.places`,
|
|
4223
|
+
`${label(l)}’s places paid from the pool “${key}” say different things about a short pool (${said.join(', ')}): every run paying from one pool must say the same (BadLottery).`,
|
|
4224
|
+
`Give each place paid from “${key}” the same "whenShort".`,
|
|
4225
|
+
);
|
|
4226
|
+
else
|
|
4227
|
+
warn(
|
|
4228
|
+
'W-LOTTERY-POOL',
|
|
4229
|
+
`${where}.places`,
|
|
4230
|
+
`${label(l)}’s places paid from the pool “${key}” read it once, when the draw is asked, and the project can spend the pool until then. When it holds less than the places with a winner win, each place gets what "whenShort" says, here ${said[0] === 'inProportion' ? '"inProportion": each place a share in proportion to its prize' : said[0] === 'nothing' ? '"nothing": no place of that pool gets anything' : '"inOrder" (the default): the first places in full, the rest what is left'}. Prizes of 1,000, 500 and 500 from a pool holding 1,700 give 1,000, 500 and 0 in order, 850, 425 and 425 in proportion, or nothing.`,
|
|
4231
|
+
'Keep the pool stocked for every place before the draw, or pay from the lottery’s own pot.',
|
|
4232
|
+
);
|
|
4233
|
+
}
|
|
4234
|
+
// A named NFT of a stash the project holds: the first winner whose prize is pulled gets it.
|
|
4235
|
+
const named = placed.filter(p => p.prize?.gives === 'nft' && !p.prize.held);
|
|
4236
|
+
const nftNames = named.map(p => p.prize.nft);
|
|
4237
|
+
if (named.length && (every > 0 || new Set(nftNames).size < nftNames.length))
|
|
4238
|
+
warn(
|
|
4239
|
+
'W-LOTTERY',
|
|
4240
|
+
`${where}.places`,
|
|
4241
|
+
`${label(l)} ${every > 0 ? 'draws again and again and names' : 'names'} ${new Set(nftNames).size < nftNames.length ? 'one NFT of a stash for several places' : 'NFTs of a stash as prizes'}: the first winner whose prize is pulled gets the NFT, and later winners get nothing.`,
|
|
4242
|
+
'Raffle NFTs from the lottery’s own stash ("raffled", or "raffle"), or give each place its own NFT in a lottery that draws once.',
|
|
4243
|
+
);
|
|
4244
|
+
if (l.ticketNft?.rule?.oneOf && l.ticketNft.rule.oneOf.length > LIMITS.ticketOneOf)
|
|
4245
|
+
refuse(
|
|
4246
|
+
'E-LOTTERY',
|
|
4247
|
+
`${where}.ticketNft.rule.oneOf`,
|
|
4248
|
+
`${label(l)}’s NFT ticket rule lists ${l.ticketNft.rule.oneOf.length} "one of" texts: the oracle’s answer reads the rule, and holds at most ${LIMITS.ticketOneOf} (BadLottery).`,
|
|
4249
|
+
`List at most ${LIMITS.ticketOneOf}, or test a number ("atLeast") instead.`,
|
|
4250
|
+
);
|
|
4251
|
+
if (l.ticketNft?.rule?.items && l.ticketNft.rule.items.length > LIMITS.ticketOneOf)
|
|
4252
|
+
refuse(
|
|
4253
|
+
'E-LOTTERY',
|
|
4254
|
+
`${where}.ticketNft.rule.items`,
|
|
4255
|
+
`${label(l)}’s NFT ticket rule names ${l.ticketNft.rule.items.length} items: the oracle’s answer reads the rule, and holds at most ${LIMITS.ticketOneOf} (BadLottery).`,
|
|
4256
|
+
`Name at most ${LIMITS.ticketOneOf}.`,
|
|
3928
4257
|
);
|
|
3929
4258
|
if (held.length)
|
|
3930
4259
|
warn(
|
|
@@ -3946,7 +4275,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3946
4275
|
`${label(l)}’s places add up to ${total / 100}% of the pot, more than all of it.`,
|
|
3947
4276
|
'Make them add up to 100% or less.',
|
|
3948
4277
|
);
|
|
3949
|
-
else if (total < 10_000)
|
|
4278
|
+
else if (total < 10_000 && !(l.noLoss && !keepsPot(l)))
|
|
3950
4279
|
warn(
|
|
3951
4280
|
'W-LOTTERY',
|
|
3952
4281
|
`${where}.places`,
|
|
@@ -3997,8 +4326,10 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
3997
4326
|
warn(
|
|
3998
4327
|
'W-LOTTERY',
|
|
3999
4328
|
where,
|
|
4000
|
-
`${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
|
|
4001
|
-
|
|
4329
|
+
`${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.${l.timeWeighted ? '' : ` Tickets are not weighted by how long they were held: someone may buy most of a round’s tickets ${closesBefore ? `just before sales close (${l.closesBefore} before the draw)` : 'right before the draw'} and risk only the fees.`}`,
|
|
4330
|
+
l.timeWeighted
|
|
4331
|
+
? undefined
|
|
4332
|
+
: 'Weigh tickets by the time they are held ("timeWeighted": true), close sales a while before the draw ("closesBefore"), keep the pot small next to what a late buyer would put in, or gate it (a stake, a list).',
|
|
4002
4333
|
);
|
|
4003
4334
|
}
|
|
4004
4335
|
rule(l.gate, `${where}.gate`, label(l));
|
|
@@ -4225,13 +4556,13 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4225
4556
|
);
|
|
4226
4557
|
for (const [i, t] of tiers.entries()) {
|
|
4227
4558
|
fourByteSeconds(t.lock, `${where}.tiers[${i}].lock`);
|
|
4228
|
-
// A
|
|
4229
|
-
if (t.prize && !(toSeconds(t.lock ?? 0) > 0))
|
|
4230
|
-
|
|
4231
|
-
'
|
|
4559
|
+
// A mission with no lock is the project's choice: one NFT can come and go and take it each time.
|
|
4560
|
+
if ((t.prize || t.roll) && !(toSeconds(t.lock ?? 0) > 0))
|
|
4561
|
+
warn(
|
|
4562
|
+
'W-FARM-MISSION',
|
|
4232
4563
|
`${where}.tiers[${i}].lock`,
|
|
4233
|
-
|
|
4234
|
-
'Give the tier a "lock" above 0.',
|
|
4564
|
+
`No lock on ${label(f)}’s mission tier ${i}: one player can stake and leave one NFT again and again and ${t.prize ? 'take the prize' : 'roll the box'} each time.`,
|
|
4565
|
+
'Give the tier a "lock" above 0, such as "1d".',
|
|
4235
4566
|
);
|
|
4236
4567
|
if (t.earnsFor !== undefined) fourByteSeconds(t.earnsFor, `${where}.tiers[${i}].earnsFor`);
|
|
4237
4568
|
const bps = Math.round((t.multiplier ?? 1) * 10_000);
|
|
@@ -4239,8 +4570,16 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4239
4570
|
refuse(
|
|
4240
4571
|
'E-FARM',
|
|
4241
4572
|
`${where}.tiers[${i}].multiplier`,
|
|
4242
|
-
`${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most
|
|
4243
|
-
|
|
4573
|
+
`${label(f)}’s tier ${i} multiplies by ${t.multiplier}: a tier is above 0 and at most x${LIMITS.maxTierBps / 10_000} (BadFarm).`,
|
|
4574
|
+
`Use a multiplier above 0, up to ${LIMITS.maxTierBps / 10_000}.`,
|
|
4575
|
+
);
|
|
4576
|
+
// A heavy tier a seat can leave early for a fee: the weight is bought for the fee.
|
|
4577
|
+
else if (bps >= 30_000 && Number(t.exitFee ?? 0) > 0 && !ofCollectibles)
|
|
4578
|
+
warn(
|
|
4579
|
+
'W-FARM',
|
|
4580
|
+
`${where}.tiers[${i}].exitFee`,
|
|
4581
|
+
`${label(f)}’s tier ${i} weighs x${t.multiplier} and lets a seat leave early for ${t.exitFee}%: a player can take the large weight, earn at it, and leave for the fee.`,
|
|
4582
|
+
'Check the fee is worth what the tier pays over its lock, or drop the early exit on heavy tiers.',
|
|
4244
4583
|
);
|
|
4245
4584
|
else if (bps < 10_000)
|
|
4246
4585
|
warn(
|
|
@@ -4252,6 +4591,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4252
4591
|
for (const [i, b] of (f.boosts ?? []).entries()) {
|
|
4253
4592
|
rule(b.when, `${where}.boosts[${i}].when`, `${label(f)}’s boost`);
|
|
4254
4593
|
effect(b.add, `${where}.boosts[${i}].add`, `${label(f)}’s boost`);
|
|
4594
|
+
// A boost per point adds its percent times its most points, at most +100,000% (rules.rs `Boost::check`).
|
|
4595
|
+
const most = Number(b.add?.perPointPercent ?? 0) * Number(b.add?.upTo ?? 0);
|
|
4596
|
+
if (b.add?.perPointPercent !== undefined && Math.round(most * 100) > LIMITS.maxBoostBps)
|
|
4597
|
+
refuse(
|
|
4598
|
+
'E-FARM',
|
|
4599
|
+
`${where}.boosts[${i}].add`,
|
|
4600
|
+
`${label(f)}’s boost adds ${b.add.perPointPercent}% a point up to ${b.add.upTo} points: +${most.toLocaleString('en-US')}% at most, past the +${(LIMITS.maxBoostBps / 100).toLocaleString('en-US')}% a farm’s boost may add (BadFarm).`,
|
|
4601
|
+
'Add less a point, or count fewer points ("upTo").',
|
|
4602
|
+
);
|
|
4255
4603
|
}
|
|
4256
4604
|
boostPercent(f.maxBoostPercent, `${where}.maxBoostPercent`);
|
|
4257
4605
|
if ((f.boosts ?? []).length && !(f.maxBoostPercent > 0))
|
|
@@ -4287,12 +4635,20 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4287
4635
|
`${label(f)}’s rate of ${f.rate.perDay} a day is less than one base unit for each 1,000,000 base units staked.`,
|
|
4288
4636
|
'Raise "perDay".',
|
|
4289
4637
|
);
|
|
4638
|
+
// A farm that mints while the project is not the mint's authority credits nothing, and what it would have paid is
|
|
4639
|
+
// not owed later; the program takes it, so the mint can be handed over after.
|
|
4290
4640
|
if (f.rate.minted && rewardsToken && !rewardsToken.gameMints)
|
|
4291
|
-
|
|
4292
|
-
'
|
|
4641
|
+
warn(
|
|
4642
|
+
'W-FARM-AUTHORITY',
|
|
4643
|
+
`${where}.rate.minted`,
|
|
4644
|
+
`${label(f)} mints its rewards, but the project is not the authority of ${rewardsToken.symbol}’s mint. It pays nothing until you set the mint’s authority to the project’s address; rewards for that time are lost, not paid later. When you hand the mint over, send \`walk_farm\` in the same transaction, just before it: then nothing from before the hand-over is ever paid; without it, the first move after the hand-over credits all the time since the farm’s last move.`,
|
|
4645
|
+
'Reward in a token the project makes (source "project"), pay the rate from its rewards vault ("minted": false), or hand the mint to the project before the farm opens.',
|
|
4646
|
+
);
|
|
4647
|
+
if (f.rate.minted && rewardsToken)
|
|
4648
|
+
warn(
|
|
4649
|
+
'W-FARM',
|
|
4293
4650
|
`${where}.rate.minted`,
|
|
4294
|
-
`${label(f)} mints
|
|
4295
|
-
'Pay the rate from its rewards vault ("minted": false), or reward in a token with source "project".',
|
|
4651
|
+
`${label(f)} mints ${rewardsToken.symbol}: if the project ever moves the mint’s authority away, stakers cannot harvest what they are owed and can only leave by giving it up.`,
|
|
4296
4652
|
);
|
|
4297
4653
|
if (!f.rate.minted)
|
|
4298
4654
|
warn(
|
|
@@ -4349,6 +4705,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4349
4705
|
if (!ofCollectibles && !(f.boosts ?? []).length)
|
|
4350
4706
|
warn('W-FARM', at, `${label(f)} stakes a token: only a collectible put on a seat as its booster grows.`);
|
|
4351
4707
|
}
|
|
4708
|
+
// A farm whose seats stop earning at times of their own keeps one entry a different second.
|
|
4709
|
+
if (tiers.some(t => t.earnsFor !== undefined) || f.upkeepAttribute !== undefined)
|
|
4710
|
+
warn(
|
|
4711
|
+
'W-FARM-ENDS',
|
|
4712
|
+
`${where}.tiers`,
|
|
4713
|
+
`${label(f)} keeps one entry for each different second at which a seat stops earning, 238,312 at most; the first staker on a new second pays about 0.0003 SOL for it. When it is full, a stake at a new end time fails until older seats are touched. A stake in a large farm needs more than the default compute (an insert took 145,279 units at the full list).`,
|
|
4714
|
+
'Expect it with many stakers: the app asks for more compute on a stake, and anyone may touch old seats for free.',
|
|
4715
|
+
);
|
|
4352
4716
|
if (f.runs !== undefined) {
|
|
4353
4717
|
const runs = fromNowSeconds(f.runs, `${where}.runs`);
|
|
4354
4718
|
for (const [i, t] of tiers.entries())
|
|
@@ -4392,13 +4756,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4392
4756
|
`${label(f)} stakes a token: a mission’s prize is for a collectible that stays its tier’s lock.`,
|
|
4393
4757
|
'Make it a farm of collectibles, or remove the prizes.',
|
|
4394
4758
|
);
|
|
4395
|
-
if (toSeconds(t.lock ?? 0) === 0)
|
|
4396
|
-
refuse(
|
|
4397
|
-
'E-FARM',
|
|
4398
|
-
at,
|
|
4399
|
-
`${label(f)}’s tier ${i} gives a prize with no lock: staking and leaving at once would give it again and again.`,
|
|
4400
|
-
'Give the mission a lock, such as "1d".',
|
|
4401
|
-
);
|
|
4402
4759
|
}
|
|
4403
4760
|
for (const [i, t] of tiers.entries()) {
|
|
4404
4761
|
if (!t.roll) continue;
|
|
@@ -4410,13 +4767,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4410
4767
|
`${label(f)} stakes a token: a mission’s roll is for a collectible leaving its seat.`,
|
|
4411
4768
|
'Make it a farm of collectibles, or remove the roll.',
|
|
4412
4769
|
);
|
|
4413
|
-
if (toSeconds(t.lock ?? 0) === 0)
|
|
4414
|
-
refuse(
|
|
4415
|
-
'E-FARM',
|
|
4416
|
-
at,
|
|
4417
|
-
`${label(f)}’s tier ${i} rolls with no lock: staking and leaving at once would roll it again and again.`,
|
|
4418
|
-
'Give the mission a lock, such as "1d".',
|
|
4419
|
-
);
|
|
4420
4770
|
if (!m.box.get(t.roll))
|
|
4421
4771
|
refuse('E-FARM', at, `${label(f)}’s tier ${i} rolls “${t.roll}”, which is no box.`, 'Name a box of this spec.');
|
|
4422
4772
|
else if (!m.box.get(t.roll).rollsOn)
|
|
@@ -4539,8 +4889,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4539
4889
|
'Name fewer deciders.',
|
|
4540
4890
|
);
|
|
4541
4891
|
const tok = token(c.currency, `${where}.currency`);
|
|
4542
|
-
|
|
4543
|
-
startsFrozen(tok, `${where}.currency`, label(c), 'E-CONTEST', 'BadContest');
|
|
4892
|
+
holdsUp(tok, `${where}.currency`, label(c), 'contest');
|
|
4544
4893
|
// Entries close no later than the result is due, and with deciders before it: a decider's verdict is taken only
|
|
4545
4894
|
// once entries close (contest.rs, BadContest). With deciders, a side left out is an hour from the other (plan.mjs
|
|
4546
4895
|
// `contestArgs`).
|
|
@@ -4553,13 +4902,6 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4553
4902
|
`${label(c)}’s entries close after its result is due (BadContest).`,
|
|
4554
4903
|
'Close entries before the result is due.',
|
|
4555
4904
|
);
|
|
4556
|
-
else if (Math.max(closes, due) > MAX_CONTEST_SECONDS)
|
|
4557
|
-
refuse(
|
|
4558
|
-
'E-CONTEST',
|
|
4559
|
-
where,
|
|
4560
|
-
`${label(c)}’s result is due more than 5 years after it opens: the program refuses to open it (BadContest).`,
|
|
4561
|
-
'Have its result due within 5 years.',
|
|
4562
|
-
);
|
|
4563
4905
|
else if ((c.deciders ?? []).length && c.entriesClose !== undefined && c.resultDue !== undefined && closes >= due)
|
|
4564
4906
|
refuse(
|
|
4565
4907
|
'E-CONTEST',
|
|
@@ -4567,6 +4909,36 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4567
4909
|
`${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).`,
|
|
4568
4910
|
'Close entries before the result is due, or leave one of "entriesClose" and "resultDue" out (an hour apart).',
|
|
4569
4911
|
);
|
|
4912
|
+
// A result far off is taken, and warned of with its date: the stakes wait until then.
|
|
4913
|
+
farTime(
|
|
4914
|
+
now + Math.max(closes, due),
|
|
4915
|
+
c.resultDue !== undefined ? `${where}.resultDue` : `${where}.entriesClose`,
|
|
4916
|
+
`${label(c)}’s result`,
|
|
4917
|
+
c.resultDue ?? c.entriesClose,
|
|
4918
|
+
'every entry’s stake waits in its pot',
|
|
4919
|
+
);
|
|
4920
|
+
// Whoever names the winner, and how the pot is paid.
|
|
4921
|
+
warn(
|
|
4922
|
+
'W-CONTEST',
|
|
4923
|
+
where,
|
|
4924
|
+
`${label(c)}’s winner is named by ${(c.deciders ?? []).length ? 'its deciders’ verdict' : 'the project’s keys'}, who can also enter it: players trust them with the result. The pot is the contest’s own account, paid only by each entry’s claim.`,
|
|
4925
|
+
);
|
|
4926
|
+
// One prize a winning entry, and nothing that costs an entry anything: one person's many wallets take one each.
|
|
4927
|
+
const stakeUnits = c.stakeNft ? 1n : quietUnits(c.stake, tok);
|
|
4928
|
+
if (c.prize && !c.prize.split && !c.gate && !c.stakeNft && !(stakeUnits > 0n))
|
|
4929
|
+
warn(
|
|
4930
|
+
'W-CONTEST-PRIZE',
|
|
4931
|
+
`${where}.prize`,
|
|
4932
|
+
`${label(c)} gives a prize to each winning entry, with no fixed stake and no gate: one person with many wallets enters from each and collects one prize a wallet.`,
|
|
4933
|
+
'Set a "stake" that makes an entry cost something, gate it, or split one prize by stake ("split": true).',
|
|
4934
|
+
);
|
|
4935
|
+
if (c.stakeNft && c.losersEach === 0)
|
|
4936
|
+
warn(
|
|
4937
|
+
'W-ZERO',
|
|
4938
|
+
`${where}.losersEach`,
|
|
4939
|
+
`${label(c)} takes 0 losers’ NFTs a winner: every loser’s NFT goes to whoever opened it, and the winners get only their own back.`,
|
|
4940
|
+
'Give "losersEach" a number above 0, or leave it out for no cap.',
|
|
4941
|
+
);
|
|
4570
4942
|
if (c.sideNames && c.sideNames.length !== c.sides)
|
|
4571
4943
|
warn(
|
|
4572
4944
|
'W-CONTEST',
|
|
@@ -4577,6 +4949,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4577
4949
|
split(c.split, tok, `${where}.split`, { what: label(c) });
|
|
4578
4950
|
rule(c.gate, `${where}.gate`, label(c));
|
|
4579
4951
|
const cp = c.prize ? prizeCheck(c.prize, `${where}.prize`, label(c), 'E-CONTEST') : null;
|
|
4952
|
+
// A prize split among the winners is set aside when the winner is named, and the program sets aside only an amount
|
|
4953
|
+
// above 0 (contest.rs `set_aside`: PrizeNotCovered). The contest opens, but no winner can ever be named.
|
|
4954
|
+
if (cp && c.prize.split && cp.gives === 'tokens' && Number(cp.amount) === 0)
|
|
4955
|
+
refuse(
|
|
4956
|
+
'E-CONTEST',
|
|
4957
|
+
`${where}.prize.amount`,
|
|
4958
|
+
`${label(c)} splits a prize of 0 among its winners: the program opens the contest but can never name its winner (PrizeNotCovered), so it runs past its result and is called off, every entry refunded.`,
|
|
4959
|
+
'Give the prize an amount above 0, or leave the prize out.',
|
|
4960
|
+
);
|
|
4580
4961
|
// One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
|
|
4581
4962
|
const scarce =
|
|
4582
4963
|
cp &&
|
|
@@ -4664,8 +5045,7 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4664
5045
|
for (const a of m.auctions) {
|
|
4665
5046
|
const where = `economy.auctions.${a.key}`;
|
|
4666
5047
|
const tok = token(a.currency, `${where}.currency`);
|
|
4667
|
-
|
|
4668
|
-
startsFrozen(tok, `${where}.currency`, label(a), 'E-AUCTION', 'BadAuction');
|
|
5048
|
+
holdsUp(tok, `${where}.currency`, label(a), 'auction');
|
|
4669
5049
|
const reserve = a.bidNfts
|
|
4670
5050
|
? BigInt(Number.isInteger(a.reserve) ? a.reserve : 1)
|
|
4671
5051
|
: units(a.reserve, tok, `${where}.reserve`, `${label(a)}’s reserve`);
|
|
@@ -4716,31 +5096,35 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4716
5096
|
`${label(a)} would be over before it goes live (BadAuction).`,
|
|
4717
5097
|
'Start it at "publish", or later.',
|
|
4718
5098
|
);
|
|
4719
|
-
// Nothing but a higher bid hands a high bid back before the end: an end, or a push of it,
|
|
4720
|
-
//
|
|
4721
|
-
if (runs > 0
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
`${where}.${start - now
|
|
4725
|
-
`${label(a)}
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
5099
|
+
// Nothing but a higher bid hands a high bid back before the end: an end, or a push of it, far off holds the bid and
|
|
5100
|
+
// the lot that long. The program takes it; the date is said in words.
|
|
5101
|
+
if (runs > 0)
|
|
5102
|
+
farTime(
|
|
5103
|
+
start + runs,
|
|
5104
|
+
`${where}.${start - now > FAR_SECONDS ? 'start' : 'runs'}`,
|
|
5105
|
+
`${label(a)}’s end`,
|
|
5106
|
+
start - now > FAR_SECONDS ? a.start : a.runs,
|
|
5107
|
+
'the high bid and the lot are held',
|
|
5108
|
+
);
|
|
5109
|
+
farTime(
|
|
5110
|
+
now + extend,
|
|
5111
|
+
`${where}.extendBy`,
|
|
5112
|
+
`${label(a)}’s end, pushed by one bid,`,
|
|
5113
|
+
a.extendBy,
|
|
5114
|
+
'that bid and the lot are held',
|
|
5115
|
+
);
|
|
5116
|
+
// No extension or no least raise: two wallets topping each other by a unit in the last seconds keep every other bid
|
|
5117
|
+
// out.
|
|
5118
|
+
const raise = Number(a.minRaise ?? 5);
|
|
5119
|
+
if (!a.bidNfts && (extend < 60 || raise < 1))
|
|
5120
|
+
warn(
|
|
5121
|
+
'W-AUCTION',
|
|
5122
|
+
`${where}.${extend < 60 ? 'extendBy' : 'minRaise'}`,
|
|
5123
|
+
`${label(a)} ${extend < 60 ? `moves its end by ${extend ? `only ${extend} seconds` : 'nothing'} after a late bid` : `takes a bid that tops the last by ${raise ? `only ${raise}%` : 'a single unit'}`}: ${extend === 0 && raise === 0 ? 'with both at zero, ' : ''}two wallets topping each other in the last seconds keep every other bid out.`,
|
|
5124
|
+
'Give it an extension of 60 seconds or more ("extendBy": "1m") and a least raise of 1% or more ("minRaise": 1).',
|
|
4734
5125
|
);
|
|
4735
5126
|
if (a.lot?.item) {
|
|
4736
5127
|
const it = item(a.lot.item, `${where}.lot.item`);
|
|
4737
|
-
if (it && it.active === false)
|
|
4738
|
-
refuse(
|
|
4739
|
-
'E-AUCTION',
|
|
4740
|
-
`${where}.lot.item`,
|
|
4741
|
-
`${label(a)} sells ${label(it)}, which is switched off (LotRefused).`,
|
|
4742
|
-
`Switch ${label(it)} on.`,
|
|
4743
|
-
);
|
|
4744
5128
|
if (it?.kind === 'normal')
|
|
4745
5129
|
warn(
|
|
4746
5130
|
'W-AUCTION',
|
|
@@ -4758,6 +5142,15 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4758
5142
|
);
|
|
4759
5143
|
randomItems = true;
|
|
4760
5144
|
} else if (a.lot?.asset) {
|
|
5145
|
+
// An NFT outside the project's collections: whoever controls its collection can stop it moving or take it
|
|
5146
|
+
// back while the project holds it.
|
|
5147
|
+
if (a.lot.collection && !m.collections.includes(a.lot.collection))
|
|
5148
|
+
warn(
|
|
5149
|
+
'W-AUCTION-OUTSIDE',
|
|
5150
|
+
`${where}.lot.collection`,
|
|
5151
|
+
`${label(a)} sells an NFT of ${a.lot.collection}, outside the project’s collections: whoever controls that collection can stop the NFT moving or take it back while the project holds it. The seller is paid only when the lot reaches the winner; while it cannot move, the winning bid waits in the project’s vault until it can or until the project’s keys call the sale off, and a lot taken back or burned sends the bid back. A bidder’s money can be tied up, not lost. Tell every bidder before a bid.`,
|
|
5152
|
+
'Check the NFT and its collection for a permanent freeze, transfer or burn delegate (Core), a rule set (a programmable Token Metadata NFT), or such delegates on a compressed NFT’s collection, before it opens.',
|
|
5153
|
+
);
|
|
4761
5154
|
if (!a.lot.collection && !m.live.project)
|
|
4762
5155
|
refuse(
|
|
4763
5156
|
'E-AUCTION',
|
|
@@ -4810,7 +5203,19 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4810
5203
|
`${label(h)} holds ${label(it)}, which is ${it.soulbound ? 'soulbound' : 'not a collectible'}: only a collectible that can move can be held.`,
|
|
4811
5204
|
'Hold a collectible that is not soulbound.',
|
|
4812
5205
|
);
|
|
4813
|
-
|
|
5206
|
+
// While a hold runs, the project's authority alone decides where the NFT goes; a far end is taken.
|
|
5207
|
+
farTime(
|
|
5208
|
+
now + seconds(h.for, `${where}.for`),
|
|
5209
|
+
`${where}.for`,
|
|
5210
|
+
`${label(h)}’s holds`,
|
|
5211
|
+
h.for,
|
|
5212
|
+
'each NFT waits with the project',
|
|
5213
|
+
);
|
|
5214
|
+
warn(
|
|
5215
|
+
'W-HOLD',
|
|
5216
|
+
where,
|
|
5217
|
+
`${label(h)}: while a hold runs, the project’s authority alone decides where its NFT goes, and it runs as long as the project sets. Players trust the project with the NFT until then.`,
|
|
5218
|
+
);
|
|
4814
5219
|
if (h.until !== undefined) {
|
|
4815
5220
|
try {
|
|
4816
5221
|
if (toTime(h.until, now) < now)
|
|
@@ -4850,6 +5255,14 @@ export function check(economy, { network = 'devnet', now = Math.floor(Date.now()
|
|
|
4850
5255
|
const keys = g.keys ?? [];
|
|
4851
5256
|
const l = g.limits ?? {};
|
|
4852
5257
|
const open = !vouchers.length && !keys.length && !(g.list ?? []).length;
|
|
5258
|
+
// A grant written again with the same list lets its wallets claim again.
|
|
5259
|
+
if ((g.list ?? []).length)
|
|
5260
|
+
warn(
|
|
5261
|
+
'W-GRANT',
|
|
5262
|
+
`${where}.list`,
|
|
5263
|
+
`${label(g)} pays the wallets on its list: if it is ever made again with the same list (closed and opened anew), they can claim again.`,
|
|
5264
|
+
'For a second round, change the list or the amounts; leave it as it is to pay each wallet once.',
|
|
5265
|
+
);
|
|
4853
5266
|
// List entries that name their own amount: tokens of a fixed amount only, each at most the grant's (OverPrize).
|
|
4854
5267
|
for (const [i, e] of (g.list ?? []).entries()) {
|
|
4855
5268
|
if (typeof e !== 'object' || e.amount === undefined) continue;
|