@xgamesfun/solana 0.26.2 → 0.26.4

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.
@@ -20,10 +20,16 @@ export const EXECUTE_DISCRIMINATOR = Uint8Array.of(105, 37, 101, 197, 75, 251, 1
20
20
 
21
21
  const ACCOUNT_TYPE_MINT = 1;
22
22
  const EXTENSION = {
23
+ TransferFeeConfig: 1,
24
+ MintCloseAuthority: 3,
25
+ ConfidentialTransferMint: 4,
23
26
  DefaultAccountState: 6,
24
27
  MemoTransfer: 8,
28
+ NonTransferable: 9,
29
+ InterestBearingConfig: 10,
25
30
  PermanentDelegate: 12,
26
31
  TransferHook: 14,
32
+ ScaledUiAmount: 25,
27
33
  PausableConfig: 26,
28
34
  };
29
35
  const META_BYTES = 35;
@@ -109,6 +115,89 @@ export function mintPowers({ owner, data }) {
109
115
  return out;
110
116
  }
111
117
 
118
+ /**
119
+ * What a mint's own keys can do, as an economy spec's `powers` says it (schema `token.powers`), read from the mint's
120
+ * account (`owner`, its token program; `data`), so nobody has to look it up and type it in. Only what is so is named:
121
+ * a plain mint nobody can mint more of reads `{ mintAuthority: 'none' }`. `project`: the loot project's address, when
122
+ * known, whose own tokens read `mintAuthority: 'project'`; any other minter is `'someone else'`.
123
+ * freeze: a freeze authority; permanentDelegate; transferHook (a hook program, or an authority who can name one);
124
+ * transferFeePercent and feeChangeable (its authority is still there); closeAuthority; pausable; defaultState (the
125
+ * mint sets what new accounts start as) and defaultFrozen (they start frozen); nonTransferable;
126
+ * confidentialTransfers.
127
+ * A mint holds two transfer fees: the one in force until an epoch, and the one from that epoch on. With `epoch` (the
128
+ * chain's current one) the fee read is the one that applies: the newer once its epoch has come, else the higher of
129
+ * the two (the fee today, or the one already set to follow). Without it, the higher of the two: never less than what
130
+ * a transfer pays, and a fee since lowered still reads at its old rate.
131
+ * Returns null when the account is not a mint of either token program.
132
+ */
133
+ export function powersOf({ owner, data }, { project = null, epoch = null } = {}) {
134
+ const classic = owner === TOKEN_PROGRAM;
135
+ if (!classic && owner !== TOKEN_2022_PROGRAM) return null;
136
+ const extended = !classic && data.length > TOKEN_ACCOUNT_BYTES && data[TOKEN_ACCOUNT_BYTES] === ACCOUNT_TYPE_MINT;
137
+ if (data.length !== MINT_BYTES && !extended) return null;
138
+ if (data[45] !== 1) return null;
139
+ const minter = readUintLE(data, 0, 4) === 1n ? encodeBase58(data.subarray(4, 36)) : null;
140
+ const out = { mintAuthority: !minter ? 'none' : project && minter === project ? 'project' : 'someone else' };
141
+ if (readUintLE(data, 46, 4) === 1n) out.freeze = true;
142
+ if (!extended) return out;
143
+ /** An address a Token-2022 extension holds at `at`, or null when it holds none (all zeros). */
144
+ const held = (value, at = 0) => {
145
+ const a = value && value.length >= at + 32 ? encodeBase58(value.subarray(at, at + 32)) : null;
146
+ return a === NO_ADDRESS ? null : a;
147
+ };
148
+ if (held(extension(data, EXTENSION.PermanentDelegate))) out.permanentDelegate = true;
149
+ const hook = extension(data, EXTENSION.TransferHook);
150
+ if (held(hook, 0) || held(hook, 32)) out.transferHook = true;
151
+ // TransferFeeConfig: its authority, the withdraw authority, what is withheld, then the fee in force and the one to
152
+ // come, each { epoch u64, most u64, basis points u16 }.
153
+ const fee = extension(data, EXTENSION.TransferFeeConfig);
154
+ if (fee && fee.length >= 108) {
155
+ const older = Number(readUintLE(fee, 88, 2)),
156
+ newer = Number(readUintLE(fee, 106, 2));
157
+ const since = readUintLE(fee, 90, 8);
158
+ const points = epoch !== null && BigInt(epoch) >= since ? newer : Math.max(older, newer);
159
+ if (points > 0) out.transferFeePercent = points / 100;
160
+ if (held(fee, 0)) out.feeChangeable = true;
161
+ }
162
+ if (held(extension(data, EXTENSION.MintCloseAuthority))) out.closeAuthority = true;
163
+ const pause = extension(data, EXTENSION.PausableConfig);
164
+ if (pause && (held(pause) || (pause.length > 32 && pause[32] !== 0))) out.pausable = true;
165
+ const state = extension(data, EXTENSION.DefaultAccountState);
166
+ if (state) {
167
+ out.defaultState = true;
168
+ if (state[0] === 2) out.defaultFrozen = true;
169
+ }
170
+ if (extension(data, EXTENSION.NonTransferable)) out.nonTransferable = true;
171
+ if (extension(data, EXTENSION.ConfidentialTransferMint)) out.confidentialTransfers = true;
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * What a Token-2022 mint's state does today that its powers don't say: `paused` (a pause is on: nothing moves),
177
+ * `scaled` (ScaledUiAmount or InterestBearing: wallets show amounts multiplied by a rate, not the base units the
178
+ * program moves and an economy counts in) and `scaler` (somebody can still change that rate). All false for a classic
179
+ * mint, or one with none of them.
180
+ */
181
+ export function mintState({ owner, data }) {
182
+ const out = { paused: false, scaled: false, scaler: false };
183
+ if (
184
+ owner !== TOKEN_2022_PROGRAM ||
185
+ data.length <= TOKEN_ACCOUNT_BYTES ||
186
+ data[TOKEN_ACCOUNT_BYTES] !== ACCOUNT_TYPE_MINT
187
+ )
188
+ return out;
189
+ const set = value => Boolean(value) && value.length >= 32 && encodeBase58(value.subarray(0, 32)) !== NO_ADDRESS;
190
+ const pause = extension(data, EXTENSION.PausableConfig);
191
+ out.paused = Boolean(pause) && pause.length > 32 && pause[32] !== 0;
192
+ for (const kind of [EXTENSION.ScaledUiAmount, EXTENSION.InterestBearingConfig]) {
193
+ const value = extension(data, kind);
194
+ if (!value) continue;
195
+ out.scaled = true;
196
+ if (set(value)) out.scaler = true;
197
+ }
198
+ return out;
199
+ }
200
+
112
201
  /** Whether a token account requires a memo on every transfer into it. */
113
202
  export function requiresMemo(accountData) {
114
203
  const value = accountData.length > TOKEN_ACCOUNT_BYTES ? extension(accountData, EXTENSION.MemoTransfer) : null;
@@ -58,6 +58,7 @@ import {
58
58
  utf8Bytes,
59
59
  wholeCount,
60
60
  weights,
61
+ tokenLabels,
61
62
  } from './model.mjs';
62
63
  // plan.mjs imports check() in turn: each module only calls the other's functions, never at load time.
63
64
  import { boxArgs, boxOrderBytes, placeholderContext, recipeArgs, setupSteps, stepAloneBytes } from './plan.mjs';
@@ -996,7 +997,7 @@ export function check(
996
997
  'E-TOKEN',
997
998
  where,
998
999
  `“${t.key}” is an existing token, so the economy needs its mint address and its decimals.`,
999
- '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.',
1000
+ 'Add "mint" (the token’s address) and "decimals": `xsolana token <mint>` (xsolana_token) reads the mint and gives the whole entry. `xsolana approve` reads the mint too and refuses decimals that differ.',
1000
1001
  );
1001
1002
  if (source === 'game' && t.program === 'token-2022')
1002
1003
  refuse(
@@ -1017,8 +1018,15 @@ export function check(
1017
1018
  // A token the setup makes is named on chain as it is made (its Token Metadata record), and the record takes so
1018
1019
  // many bytes of each, not characters: a longer one would be refused on chain, in the middle of going live. One
1019
1020
  // with no name or ticker at all would be written blank, and show as an unknown token for the record's price. A
1020
- // token that is live already is not named again: its words here stay labels, as they were.
1021
- if (source === 'game' && !economy.live?.mints?.[t.key])
1021
+ // token that is live keeps its words as labels, held to nothing, until they are changed: a change is written to
1022
+ // its record, so the new words are held to what a record takes.
1023
+ const liveAs = economy.live?.mints?.[t.key]
1024
+ ? (economy.live.published?.tokens ?? []).find(x => x.key === t.key)
1025
+ : null;
1026
+ const written =
1027
+ !economy.live?.mints?.[t.key] ||
1028
+ (liveAs && JSON.stringify(tokenLabels(liveAs)) !== JSON.stringify(tokenLabels(t)));
1029
+ if (source === 'game' && written)
1022
1030
  for (const [field, what] of [
1023
1031
  ['name', 'name'],
1024
1032
  ['symbol', 'ticker'],
@@ -1110,7 +1118,7 @@ export function check(
1110
1118
  'W-MINT-POWERS',
1111
1119
  where,
1112
1120
  `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).`,
1113
- 'Look the mint up on an explorer and list what it can do under "powers".',
1121
+ `Run \`xsolana token ${t.mint ?? '<mint>'}\` (xsolana_token): it reads the mint and gives this entry with "powers" filled in.`,
1114
1122
  );
1115
1123
  }
1116
1124
  if (!m.currency) {
@@ -5003,11 +5011,41 @@ export function check(
5003
5011
  `${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.`,
5004
5012
  'Give the prize an amount above 0, or leave the prize out.',
5005
5013
  );
5014
+ // A presale: one side, entries paid in money (SOL, or a coin made elsewhere), and another token split among the
5015
+ // backers by what each put in: people pay for a share of a token, which is what the law on selling securities
5016
+ // looks at first. The prize's own token is read (a pool's is what the pool holds), and entries paid in points
5017
+ // the project mints are no sale.
5018
+ const sold = cp?.gives === 'tokens' ? prizeToken(c.prize, m) : null;
5019
+ if (
5020
+ c.prize?.split &&
5021
+ c.sides === 1 &&
5022
+ sold &&
5023
+ !sold.wsol &&
5024
+ sold.source !== 'sol' &&
5025
+ tok &&
5026
+ tok.source !== 'game' &&
5027
+ sold.key !== tok.key
5028
+ )
5029
+ warn(
5030
+ 'W-TOKEN-SALE',
5031
+ `${where}.prize`,
5032
+ `${label(c)} sells ${sold.symbol ?? sold.key} for ${tok.symbol ?? tok.key}: each backer pays in and takes a share of the tokens by what they put in. Selling a token to raise money is what securities law looks at first, and many places limit who may buy.`,
5033
+ 'Fine on devnet. Before mainnet, ask counsel.',
5034
+ );
5006
5035
  // One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
5007
5036
  const scarce =
5008
5037
  cp &&
5009
5038
  (cp.gives === 'nft' || (cp.gives === 'item' && Number(m.item.get(cp.item)?.supply ?? 0) > 0) || prizePool(cp));
5010
- if (scarce)
5039
+ // A prize split by stake is set aside whole when the winner is named, or that decision is refused: no entry takes
5040
+ // another's part, but the pool must hold all of it by then.
5041
+ if (scarce && c.prize.split && prizePool(cp))
5042
+ warn(
5043
+ 'W-CONTEST-PRIZE',
5044
+ `${where}.prize`,
5045
+ `${label(c)} splits ${prizeWords(cp, m)} among its winners: all of it is set aside when the winner is named, and naming one is refused while “${prizePool(cp)}” holds less (PrizeNotCovered), so the contest would run past its result and be called off.`,
5046
+ 'Keep the pool holding the whole prize until the winner is named: seed it with that much, and let nothing else pay out of it.',
5047
+ );
5048
+ else if (scarce)
5011
5049
  warn(
5012
5050
  'W-CONTEST-PRIZE',
5013
5051
  `${where}.prize`,
@@ -39,9 +39,10 @@ import { inAll, round, simulate } from './simulate.mjs';
39
39
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
40
40
 
41
41
  const sol = lamports => round(Number(lamports) / Number(LAMPORTS_PER_SOL));
42
- /** Setup steps that are no loot instruction, and so charge no fee: a token minted, the collection made, SOL put in a
43
- * grant's funds. Every other step is the owner or the authority calling loot, which charges the project fee. */
44
- const NO_FEE_STEP = /^(token:|collection$|fund:|rent:)/;
42
+ /** Setup steps that are no loot instruction, and so charge no fee: a token minted, a token's name written again, the
43
+ * collection made, SOL put in a grant's funds. Every other step is the owner or the authority calling loot, which
44
+ * charges the project fee. */
45
+ const NO_FEE_STEP = /^(token:|name:|collection$|fund:|rent:)/;
45
46
  /** Setup cost from a plan (plan.mjs `plan`). */
46
47
  export function setupCost(plan, economy) {
47
48
  const m = model(economy);
@@ -473,8 +474,8 @@ export function actionCosts(economy, { now } = NO_OPTIONS) {
473
474
 
474
475
  /** Setup and play costs for a spec, or its refusals. */
475
476
  /** `only` and `live`: a change to a live economy, priced as only what it publishes (live.mjs `changedParts`). */
476
- export function cost(economy, { now = Math.floor(Date.now() / 1000), only = null, live = null } = {}) {
477
- const plan = compile(economy, { now, only, live });
477
+ export function cost(economy, { now = Math.floor(Date.now() / 1000), only = null, live = null, renames = null } = {}) {
478
+ const plan = compile(economy, { now, only, live, renames });
478
479
  if (!plan.ok) return plan;
479
480
  return {
480
481
  ok: true,
@@ -205,14 +205,15 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
205
205
  if (economy.prompt) lines.push(`Asked for: “${economy.prompt}”`);
206
206
 
207
207
  // A token the setup makes is named on chain as it is made (plan.mjs): what wallets will show, and that a picture
208
- // needs its link before going live. One that is live already keeps what it has.
208
+ // needs its link before going live. One that is live already keeps what it has until the spec changes its name,
209
+ // which is written at the next approve (live.mjs changedParts, approve/live-check.mjs readRenames).
209
210
  section(
210
211
  'The tokens going live makes',
211
212
  [...m.tokens.values()]
212
213
  .filter(t => t.source === 'game' && !economy.live?.mints?.[t.key])
213
214
  .map(
214
215
  t =>
215
- `${t.symbol}${t.name !== t.symbol ? ` (“${t.name}”)` : ''}: ${t.fixedSupply ? `a fixed supply of ${num(t.supply)}, all of it to your wallet, and no more ever` : 'minted by the project as its rules say'}. It is named on chain as it is made, so wallets show ${t.uri ? 'its name, its ticker and the picture its link gives' : 'its name and ticker and no picture: for one, give it a `uri` (a link to a JSON file that names its `image`) before going live'}. Only your wallet can change the name, ticker and link later, and these tools don’t change a live token’s yet: get them right first.`,
216
+ `${t.symbol}${t.name !== t.symbol ? ` (“${t.name}”)` : ''}: ${t.fixedSupply ? `a fixed supply of ${num(t.supply)}, all of it to your wallet, and no more ever` : 'minted by the project as its rules say'}. It is named on chain as it is made, so wallets show ${t.uri ? 'its name, its ticker and the picture its link gives' : 'its name and ticker and no picture: for one, give it a `uri` (a link to a JSON file that names its `image`) before going live'}. Only your wallet can change the name, ticker and link later: change them here and approve again.`,
216
217
  ),
217
218
  );
218
219
 
@@ -603,7 +604,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
603
604
  );
604
605
  for (const n of sim.notes) lines.push(`- ${n}`);
605
606
 
606
- const flagged = verdict.warnings.filter(w => w.code === 'W-CHANCE-PRIZE' || w.code === 'W-REVENUE-SHARE');
607
+ const flagged = verdict.warnings.filter(w => ['W-CHANCE-PRIZE', 'W-REVENUE-SHARE', 'W-TOKEN-SALE'].includes(w.code));
607
608
  const others = verdict.warnings.filter(w => !flagged.includes(w));
608
609
  if (flagged.length) section('Flagged for the law, once it is real money', [...new Set(flagged.map(w => w.message))]);
609
610
  section(
@@ -10,7 +10,7 @@ const sol = lamports => Number(lamports) / Number(LAMPORTS_PER_SOL);
10
10
  export const PARTS = {
11
11
  tokens: {
12
12
  def: 'token',
13
- says: 'The currencies: SOL, any existing mint by address (classic or Token-2022, coins from a token launchpad or an AMM included), or a token the game makes when it goes live: one the game mints as its rules say (points, tickets, shards, an emission), or one with a fixed `supply` minted once to the owner with minting turned off for good (a community token you keep). A token the game makes is named on chain as it is made (`name`, `symbol`, and `uri`: a link to a JSON file naming its picture), which is what wallets show; only the owner’s wallet can change that later, and changing them in a spec that is already live changes nothing on chain yet. loot does not put a token on a market: for trading, launch it on a token launchpad or an AMM, then name the mint.',
13
+ says: 'The currencies: SOL, any existing mint by address (classic or Token-2022, coins from a token launchpad or an AMM included), or a token the game makes when it goes live: one the game mints as its rules say (points, tickets, shards, an emission), or one with a fixed `supply` minted once to the owner with minting turned off for good (a community token you keep). A token the game makes is named on chain as it is made (`name`, `symbol`, and `uri`: a link to a JSON file naming its picture), which is what wallets show; only the owner’s wallet can change that later, by changing them in the spec and approving again. loot does not put a token on a market: for trading, launch it on a token launchpad or an AMM, then name the mint. `xsolana token <mint>` (xsolana_token) reads any existing mint into its entry, with its decimals, program and `powers` (what its own keys can do), so none of it is typed by hand.',
14
14
  acts: 'nobody: a token is a fact',
15
15
  },
16
16
  currency: {
@@ -160,7 +160,7 @@ export const CANNOT = [
160
160
  words: 'bonding curve market trading fees launch pump.fun meteora swap liquidity sell back creator coin',
161
161
  why: 'loot runs no market: it moves and sells what the game owns, it doesn’t trade between strangers',
162
162
  nearest: [
163
- 'launch the coin on a token launchpad or an AMM (a launchpad’s creator fees pay on trades), then name its mint as an existing token: loot’s shops, boxes, farms and gates use it; gives up nothing on loot’s side',
163
+ 'launch the coin on a token launchpad or an AMM (a launchpad’s creator fees pay on trades), then name its mint as an existing token (`xsolana token <mint>` reads it into its entry): loot’s shops, boxes, farms and gates use it; gives up minting, since a coin launched this way usually has nobody left who can mint it, so the economy pays out only what its owner puts into its pools',
164
164
  'a shop listing whose price rises after each sale (`rise`), and a recipe that buys the token back at a fixed price from a reserve pool a share of each sale fills (`in`: a `pay` line burned; `out`: tokens from the pool): a one-way curve with a floor; gives up a market price, and the reserve is the authority’s to spend',
165
165
  'a game token with a fixed `supply` minted once to the owner, minting off for good: a community token the owner keeps and hands out; gives up a public market',
166
166
  ],
@@ -12,6 +12,8 @@ export const LEGAL = Object.freeze({
12
12
  'Anyone, the seller included, can bid to push an auction’s price up (shill bidding): a reserve is the honest way to set a floor.',
13
13
  'W-REVENUE-SHARE':
14
14
  'Stakers are paid from what players spend, which regulators look at when they decide whether a token is a security: before real money, get a lawyer, or pay rewards from emissions instead.',
15
+ 'W-TOKEN-SALE':
16
+ 'People pay for a share of a new token (a presale). Selling a token to raise money is what securities law looks at first, and many places limit who may buy: before real money, get a lawyer.',
15
17
  });
16
18
 
17
19
  /** The legal flags a checker verdict raises, each once, in the order the checker raised them: [{ code, says }]. */
@@ -3,7 +3,7 @@
3
3
  import { encodeBase58 } from '../chain/base58.js';
4
4
  import { randomBytes } from '../chain/bytes.js';
5
5
  import { LOOT_PROGRAM } from '../client/pda.js';
6
- import { compressedTree, listOf, model } from './model.mjs';
6
+ import { compressedTree, listOf, model, tokenLabels } from './model.mjs';
7
7
  import { auctionSeller, plan, setupContext } from './plan.mjs';
8
8
 
9
9
  /** A key for each fresh account the plan creates. Their secret halves are made by whoever signs (the page). */
@@ -17,12 +17,12 @@ export function freshAddresses(names, random = randomBytes) {
17
17
  */
18
18
  export function prepare(
19
19
  economy,
20
- { owner, keys, live = null, now = Math.floor(Date.now() / 1000), rent, only = null, slot = null },
20
+ { owner, keys, live = null, now = Math.floor(Date.now() / 1000), rent, only = null, slot = null, renames = null },
21
21
  ) {
22
22
  const m = model(economy, { now });
23
23
  // `slot`: a recent slot (in the slot hashes now), which the project's standing lookup table is made at.
24
24
  const ctx = { ...setupContext(m, { owner, keys, live: live ?? {}, rent }), slot };
25
- const p = plan(economy, { now, ctx, live, only });
25
+ const p = plan(economy, { now, ctx, live, only, renames });
26
26
  if (!p.ok) return p;
27
27
  // Going live always makes the project's standing lookup table (the plan's "table" step). Without a recent slot it
28
28
  // can't be made: refused when a part's widest transaction needs it (`wideParts`), so no player is left with a
@@ -133,10 +133,11 @@ const FIXED = new Set(['farm', 'pool', 'auction']);
133
133
  */
134
134
  export function changedParts(economy) {
135
135
  const published = economy.live?.published;
136
- if (!published) return { only: null, fixed: [], removed: [], settings: [] };
136
+ if (!published) return { only: null, fixed: [], removed: [], settings: [], renames: [] };
137
137
  const only = new Set();
138
138
  const fixed = [];
139
139
  const removed = [];
140
+ const renames = [];
140
141
  // A grant's `trust` is the spec's own note about its server, never sent on chain: changing it publishes nothing.
141
142
  const strip = ({ id, index, shopBox, seller, trust, ...rest }) => JSON.stringify(rest);
142
143
  for (const [list, kind] of Object.entries(PART_LISTS)) {
@@ -155,8 +156,20 @@ export function changedParts(economy) {
155
156
  }
156
157
  for (const key of before.keys()) removed.push(`${kind}:${key}`);
157
158
  }
158
- for (const t of economy.tokens ?? [])
159
- if (!(published.tokens ?? []).some(x => x.key === t.key)) only.add(`token:${t.key}`);
159
+ // A token is made once. One whose name, ticker or link the spec now gives otherwise than it was published with
160
+ // (`renames`; by what they come to, not how they are spelled: model.mjs tokenLabels) has its name record written
161
+ // again, when it has one its owner can still change: that is read from the chain as the change is approved
162
+ // (approve/live-check.mjs readRenames), never taken from this file.
163
+ for (const t of economy.tokens ?? []) {
164
+ const was = (published.tokens ?? []).find(x => x.key === t.key);
165
+ if (!was) only.add(`token:${t.key}`);
166
+ else if (
167
+ ['project', 'game'].includes(t.source) &&
168
+ economy.live.mints?.[t.key] &&
169
+ JSON.stringify(tokenLabels(was)) !== JSON.stringify(tokenLabels(t))
170
+ )
171
+ renames.push(t.key);
172
+ }
160
173
  // The first compressed prize opens the project's tree.
161
174
  if (!economy.live.tree && compressedTree(model(economy))) only.add('tree');
162
175
  // A traits root set again replaces the last.
@@ -168,5 +181,12 @@ export function changedParts(economy) {
168
181
  if ((published.treasury ?? null) !== (economy.treasury ?? null)) settings.push('its treasury');
169
182
  if (JSON.stringify(published.collections ?? []) !== JSON.stringify(economy.collections ?? []))
170
183
  settings.push('the collections it takes');
171
- return { only, fixed, removed, settings };
184
+ return { only, fixed, removed, settings, renames };
172
185
  }
186
+
187
+ /**
188
+ * In words, for a tool that does not read the chain: the spec changes what these tokens are called, and what becomes
189
+ * of that is decided when the change is approved.
190
+ */
191
+ export const renamesNote = renames =>
192
+ `The spec changes what ${renames.join(', ')} ${renames.length === 1 ? 'is' : 'are'} called. A token’s name on chain is its name record: approving reads it, and writes the new name, ticker and link there when the token has a record its owner can still change (and nothing when the record says them already). A token that went live before setups named tokens has no record and can’t be given one, and a record handed on, made unchangeable or given a royalty outside these tools is left as it is: the approval page says so, sends nothing for that token, and the change stays listed here until the spec gives its old name back.`;
@@ -392,6 +392,16 @@ export const spendOf = (then, m) => {
392
392
  return { Give: [then.giveTo] };
393
393
  };
394
394
 
395
+ /**
396
+ * What a token the setup makes is called on chain, from its entry in a spec as written: [name, ticker, link], with
397
+ * the names it gets when the spec gives none (`tokenOf`: its key in capitals for a ticker, its ticker or key for a
398
+ * name). Two entries with the same labels write the same record, however they are spelled.
399
+ */
400
+ export function tokenLabels(t) {
401
+ const symbol = t.symbol ?? String(t.key).toUpperCase().slice(0, 10);
402
+ return [t.name ?? t.symbol ?? t.key, symbol, t.uri ?? ''];
403
+ }
404
+
395
405
  /** What a token is on chain: its mint (or "@mint:<key>" until it exists), program, decimals, and who can mint it. */
396
406
  function tokenOf(given) {
397
407
  // "project" is the spec's word for a token the project mints; "game" is the older spelling of it.
@@ -408,10 +418,11 @@ function tokenOf(given) {
408
418
  gameMints: false,
409
419
  };
410
420
  const decimals = t.decimals ?? (t.source === 'game' ? 0 : undefined);
421
+ const [name, symbol] = tokenLabels(t);
411
422
  return {
412
423
  ...t,
413
- symbol: t.symbol ?? t.key.toUpperCase().slice(0, 10),
414
- name: t.name ?? t.symbol ?? t.key,
424
+ symbol,
425
+ name,
415
426
  decimals,
416
427
  program: t.program ?? 'spl-token',
417
428
  mint: t.mint ?? `@mint:${t.key}`,
@@ -21,7 +21,13 @@ import {
21
21
  TOKEN_PROGRAM,
22
22
  } from '../chain/pda.js';
23
23
  import { createTableIx, extendTableIx } from '../chain/lookup.js';
24
- import { createFungibleMetadataIx, METADATA_BYTES, METADATA_FEE, METADATA_LIMITS } from '../chain/metadata.js';
24
+ import {
25
+ createFungibleMetadataIx,
26
+ METADATA_BYTES,
27
+ METADATA_FEE,
28
+ METADATA_LIMITS,
29
+ updateFungibleMetadataIx,
30
+ } from '../chain/metadata.js';
25
31
  import { MEMO_PROGRAM } from '../chain/token2022.js';
26
32
  import {
27
33
  computeUnitLimit,
@@ -78,6 +84,7 @@ import {
78
84
  weights,
79
85
  wholeCount,
80
86
  windowOf,
87
+ tokenLabels,
81
88
  } from './model.mjs';
82
89
  import { BUDGET } from './txsize.mjs';
83
90
 
@@ -723,7 +730,7 @@ const placeholderKey = name => encodeBase58(sha256(utf8(`loot-plan:${name}`)));
723
730
  * seats there as it is released, its clock stopped while nobody is in. A seed that lands
724
731
  * before the first stake or after it is released the same way from that stake on, so seeding first would change nothing.
725
732
  */
726
- export function setupSteps(economy, { now = 0, only = null, live: given = null } = {}) {
733
+ export function setupSteps(economy, { now = 0, only = null, live: given = null, renames = null } = {}) {
727
734
  const m = model(economy, { now });
728
735
  const live = madeSoFar(given);
729
736
  const steps = [];
@@ -731,19 +738,51 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
731
738
  const landed = new Set(live?.landed ?? []);
732
739
  const add = step => want(step.part) && !landed.has(step.id) && steps.push({ creates: [], signers: [owner], ...step });
733
740
  const project = ctx => ctx.project;
734
- const fresh = !live?.project;
741
+ // The project's own first steps (its collection, the project, the accounts it pays into, its lookup table) belong to
742
+ // a new setup, and to one cut short before the file was recorded as live (`partial`, with nothing `published`): gone
743
+ // on with, whichever of them has not landed is still made. Before, a setup cut short after the project's step never
744
+ // made the rest.
745
+ const fresh = !live?.project || Boolean(given?.partial && !given?.published);
735
746
 
736
747
  // Tokens the project mints: a classic mint with the owner as its authority, handed to the project right after.
737
748
  // Each is named on chain in the same step (its Token Metadata record: the name, the ticker and the link wallets
738
749
  // show), while the owner is still its mint authority: Token Metadata takes a record from nobody else, so a mint whose
739
750
  // minting was handed on or turned off without one can never have one. The owner stays the record's update authority.
740
751
  for (const t of m.tokens.values()) {
752
+ // A live token whose name record is to be written again (`renames`: its key; the approval reads the chain for
753
+ // which, live-check.mjs readRenames): the new name, ticker and link, signed by the owner, the record's update
754
+ // authority. Not a part a change scopes (`only`), and never held back as landed: the chain says when it is done.
755
+ const labels = [t.name, t.symbol, t.uri ?? ''];
756
+ const writable =
757
+ labels.every(
758
+ (v, i) => utf8(String(v)).length <= [METADATA_LIMITS.name, METADATA_LIMITS.symbol, METADATA_LIMITS.uri][i],
759
+ ) &&
760
+ String(t.name).trim() !== '' &&
761
+ String(t.symbol).trim() !== '';
762
+ if (t.source === 'game' && live?.mints?.[t.key] && renames && new Set(renames).has(t.key) && writable)
763
+ steps.push({
764
+ creates: [],
765
+ signers: [owner],
766
+ id: `name:${t.key}`,
767
+ part: `name:${t.key}`,
768
+ // (named by the ticker it was published with: the new one is what it is changed to)
769
+ says: `Change what the token ${tokenLabels((given?.published?.tokens ?? []).find(x => x.key === t.key) ?? t)[1]} (mint ${live.mints[t.key]}) is called on chain to “${t.name}”, ${t.symbol}, ${t.uri ? `with the link ${t.uri}` : 'no picture link'}: its name record is written again, and stays only your wallet’s to change`,
770
+ build: ctx => [
771
+ updateFungibleMetadataIx({
772
+ mint: ctx.mints[t.key],
773
+ authority: ctx.owner,
774
+ name: t.name,
775
+ symbol: t.symbol,
776
+ uri: t.uri ?? '',
777
+ }),
778
+ ],
779
+ });
741
780
  if (t.source !== 'game' || live?.mints?.[t.key] || (t.mint && !t.mint.startsWith('@'))) continue;
742
781
  const key = `new:mint/${t.key}`;
743
- const named = `write its name on chain (“${t.name}”, ${t.symbol}, ${t.uri ? `with the link ${t.uri}` : 'no picture link'}: only your wallet can change it later, which these tools don’t do yet, and Token Metadata keeps ${Number(METADATA_FEE) / 1e9} SOL for it)`;
782
+ const named = `write its name on chain (“${t.name}”, ${t.symbol}, ${t.uri ? `with the link ${t.uri}` : 'no picture link'}: only your wallet can change it later, and Token Metadata keeps ${Number(METADATA_FEE) / 1e9} SOL for it)`;
744
783
  const record = { what: `the name of ${t.symbol} on chain`, bytes: METADATA_BYTES, fee: Number(METADATA_FEE) };
745
784
  // The checker refuses a name, ticker or link too long for a record before any plan is made, except on a token
746
- // that is live already, whose words are only labels. Such a token is made here only when a live economy is
785
+ // that is live already and whose words were not changed. Such a token is made here only when a live economy is
747
786
  // planned as if it were new (explain's cost of the whole setup, which is never sent): its record is left out.
748
787
  const fits = ['name', 'symbol', 'uri'].every(f => utf8(String(t[f] ?? '')).length <= METADATA_LIMITS[f]);
749
788
  const name = ctx =>
@@ -926,26 +965,39 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
926
965
  // the first of its addresses, then extended a group a step.
927
966
  if (fresh) {
928
967
  const named = standingAddresses(m);
929
- groupsOf(named, TABLE_GROUP).forEach((group, i) =>
930
- add({
931
- id: i ? `table:${i}` : 'table',
932
- part: 'project',
933
- says: i
934
- ? `Add ${group.length} more addresses to the project’s lookup table`
935
- : `Make the project’s lookup table of the ${named.length} addresses its transactions name most, so a large one takes one approval`,
936
- creates: i
937
- ? [{ what: `room for ${group.length} addresses in the lookup table`, bytes: 32 * group.length, grows: true }]
938
- : [{ what: 'the project’s lookup table', bytes: 56 + 32 * group.length }],
939
- build: ctx => {
940
- // With no recent slot known (a plan built offline), there is no table to make.
941
- if (ctx.slot == null) return [];
942
- const { address: table, instruction } = createTableIx({ authority: ctx.owner, slot: ctx.slot });
943
- return [
944
- ...(i ? [] : [instruction]),
945
- extendTableIx({ table, authority: ctx.owner, addresses: group.map(at => at(ctx)).filter(Boolean) }),
946
- ];
947
- },
948
- }),
968
+ // A table whose making landed with no address kept (a file an earlier release left, which recorded a transaction
969
+ // found landed without what it made) can't be added to: its address is of a slot nobody recorded. It stays as it
970
+ // is, and nothing more is planned for it.
971
+ const lost = landed.has('table') && !given?.tables?.[0];
972
+ groupsOf(named, TABLE_GROUP).forEach(
973
+ (group, i) =>
974
+ (i && lost) ||
975
+ add({
976
+ id: i ? `table:${i}` : 'table',
977
+ part: 'project',
978
+ says: i
979
+ ? `Add ${group.length} more addresses to the project’s lookup table`
980
+ : `Make the project’s lookup table of the ${named.length} addresses its transactions name most, so a large one takes one approval`,
981
+ creates: i
982
+ ? [
983
+ {
984
+ what: `room for ${group.length} addresses in the lookup table`,
985
+ bytes: 32 * group.length,
986
+ grows: true,
987
+ },
988
+ ]
989
+ : [{ what: 'the project’s lookup table', bytes: 56 + 32 * group.length }],
990
+ build: ctx => {
991
+ const addresses = group.map(at => at(ctx)).filter(Boolean);
992
+ // A table made already (its first step landed in an earlier batch, or before the setup was cut short) is the
993
+ // one the rest is added to: its address is of the slot it was made at, never of this batch's.
994
+ if (i && ctx.tables?.[0]) return [extendTableIx({ table: ctx.tables[0], authority: ctx.owner, addresses })];
995
+ // With no recent slot known (a plan built offline), there is no table to make.
996
+ if (ctx.slot == null) return [];
997
+ const { address: table, instruction } = createTableIx({ authority: ctx.owner, slot: ctx.slot });
998
+ return [...(i ? [] : [instruction]), extendTableIx({ table, authority: ctx.owner, addresses })];
999
+ },
1000
+ }),
949
1001
  );
950
1002
  }
951
1003
 
@@ -1809,6 +1861,8 @@ export function setupContext(m, { owner: ownerKey, keys, live: given = {}, rent
1809
1861
  project: own,
1810
1862
  treasury: live.treasury ?? m.treasuryWallet ?? ownerKey,
1811
1863
  mints,
1864
+ // the project's lookup table, once its first step landed
1865
+ tables: live.tables ?? null,
1812
1866
  keys,
1813
1867
  rent,
1814
1868
  };
@@ -1876,11 +1930,14 @@ export function pack(steps, ctx) {
1876
1930
  * The plan for a spec: refusals, or every step packed into transactions. With `ctx` the transactions carry the
1877
1931
  * real instructions; without it they are built on stand-in addresses (for sizing, cost and showing).
1878
1932
  */
1879
- export function plan(economy, { now = Math.floor(Date.now() / 1000), ctx = null, only = null, live = null } = {}) {
1933
+ export function plan(
1934
+ economy,
1935
+ { now = Math.floor(Date.now() / 1000), ctx = null, only = null, live = null, renames = null } = {},
1936
+ ) {
1880
1937
  // With a real context the project is known: a box's write is checked with its own address searches counted.
1881
1938
  const checked = check(economy, { now, ...(ctx?.project ? { project: ctx.project } : {}) });
1882
1939
  if (!checked.ok) return { ok: false, refusals: checked.refusals, warnings: checked.warnings };
1883
- const { steps, model: m } = setupSteps(economy, { now, only, live });
1940
+ const { steps, model: m } = setupSteps(economy, { now, only, live, renames });
1884
1941
  if (ctx)
1885
1942
  ctx.tokenPrograms ??= Object.fromEntries(
1886
1943
  [...m.tokens.values()]
@@ -363,7 +363,7 @@
363
363
  "type": "object",
364
364
  "required": ["key", "source"],
365
365
  "additionalProperties": false,
366
- "description": "A currency. `sol`: SOL (players pay in wrapped SOL). `existing`: any mint by address, launched anywhere, classic or Token-2022, pump.fun included (xgames.fun takes no share of its fees). `project` (`game` is its older spelling): a token made when the economy goes live: the project mints it as its rules say (points, tickets, shards), or, with `supply`, a fixed supply minted once to the owner (a community token you keep). A token made this way is named on chain as it is made (its `name`, `symbol` and `uri`), which is what wallets show. Only the owner's wallet can change that later, and a change to them in a spec that is already live changes nothing on chain yet.",
366
+ "description": "A currency. `sol`: SOL (players pay in wrapped SOL). `existing`: any mint by address, launched anywhere, classic or Token-2022, pump.fun included (xgames.fun takes no share of its fees). `project` (`game` is its older spelling): a token made when the economy goes live: the project mints it as its rules say (points, tickets, shards), or, with `supply`, a fixed supply minted once to the owner (a community token you keep). A token made this way is named on chain as it is made (its `name`, `symbol` and `uri`), which is what wallets show. Only the owner's wallet can change that later: a change to them in a spec that is live is written to the token's name record at the next approve, when it has one its owner can still change (a token that went live before setups named tokens has none).",
367
367
  "properties": {
368
368
  "key": {
369
369
  "$ref": "#/$defs/key"
@@ -2037,6 +2037,12 @@
2037
2037
  "tree": {
2038
2038
  "$ref": "#/$defs/address"
2039
2039
  },
2040
+ "tables": {
2041
+ "type": "array",
2042
+ "items": {
2043
+ "$ref": "#/$defs/address"
2044
+ }
2045
+ },
2040
2046
  "mints": {
2041
2047
  "type": "object",
2042
2048
  "additionalProperties": {