@xgamesfun/solana 0.26.1 → 0.26.3

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;
@@ -14,6 +14,7 @@ import { TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from '../chain/pda.js';
14
14
  import { addressBytes, encodeBase58 } from '../chain/base58.js';
15
15
  import { isOnCurve } from '../chain/curve.js';
16
16
  import { utf8 } from '../chain/bytes.js';
17
+ import { METADATA_LIMITS } from '../chain/metadata.js';
17
18
  import { sha256 } from '../chain/sha256.js';
18
19
  import {
19
20
  FEES,
@@ -995,7 +996,7 @@ export function check(
995
996
  'E-TOKEN',
996
997
  where,
997
998
  `“${t.key}” is an existing token, so the economy needs its mint address and its decimals.`,
998
- '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.',
999
+ '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.',
999
1000
  );
1000
1001
  if (source === 'game' && t.program === 'token-2022')
1001
1002
  refuse(
@@ -1013,6 +1014,33 @@ export function check(
1013
1014
  `“${t.key}” is a token the project makes when the economy goes live, but it names the mint ${t.mint}: the setup would never make it, and would mint from an address the project has no say over.`,
1014
1015
  'Remove "mint" for a token the setup makes, or make it an existing token (source "existing", with its "decimals").',
1015
1016
  );
1017
+ // A token the setup makes is named on chain as it is made (its Token Metadata record), and the record takes so
1018
+ // many bytes of each, not characters: a longer one would be refused on chain, in the middle of going live. One
1019
+ // 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])
1022
+ for (const [field, what] of [
1023
+ ['name', 'name'],
1024
+ ['symbol', 'ticker'],
1025
+ ['uri', 'link'],
1026
+ ]) {
1027
+ const value = String(tok[field] ?? '');
1028
+ const bytes = utf8(value).length;
1029
+ if (bytes > METADATA_LIMITS[field])
1030
+ refuse(
1031
+ 'E-TOKEN',
1032
+ `${where}.${field}`,
1033
+ `The ${what} of “${t.key}” is ${bytes} bytes, and a token’s ${what} on chain is at most ${METADATA_LIMITS[field]} (a letter outside plain English takes two or more).`,
1034
+ `Shorten it to ${METADATA_LIMITS[field]} bytes${field === 'uri' ? ', for instance with a shorter path or host' : ''}.`,
1035
+ );
1036
+ else if (field !== 'uri' && !value.trim())
1037
+ refuse(
1038
+ 'E-TOKEN',
1039
+ `${where}.${field}`,
1040
+ `“${t.key}” has an empty ${what}: going live writes it on chain as it is, and wallets would show a token with no ${what}.`,
1041
+ `Give it a ${what}, or leave "${field}" out (${field === 'symbol' ? 'its key in capitals is used' : 'its ticker is used'}).`,
1042
+ );
1043
+ }
1016
1044
  if (t.supply !== undefined && source !== 'game')
1017
1045
  refuse(
1018
1046
  'E-TOKEN',
@@ -1082,7 +1110,7 @@ export function check(
1082
1110
  'W-MINT-POWERS',
1083
1111
  where,
1084
1112
  `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).`,
1085
- 'Look the mint up on an explorer and list what it can do under "powers".',
1113
+ `Run \`xsolana token ${t.mint ?? '<mint>'}\` (xsolana_token): it reads the mint and gives this entry with "powers" filled in.`,
1086
1114
  );
1087
1115
  }
1088
1116
  if (!m.currency) {
@@ -4975,11 +5003,41 @@ export function check(
4975
5003
  `${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.`,
4976
5004
  'Give the prize an amount above 0, or leave the prize out.',
4977
5005
  );
5006
+ // A presale: one side, entries paid in money (SOL, or a coin made elsewhere), and another token split among the
5007
+ // backers by what each put in: people pay for a share of a token, which is what the law on selling securities
5008
+ // looks at first. The prize's own token is read (a pool's is what the pool holds), and entries paid in points
5009
+ // the project mints are no sale.
5010
+ const sold = cp?.gives === 'tokens' ? prizeToken(c.prize, m) : null;
5011
+ if (
5012
+ c.prize?.split &&
5013
+ c.sides === 1 &&
5014
+ sold &&
5015
+ !sold.wsol &&
5016
+ sold.source !== 'sol' &&
5017
+ tok &&
5018
+ tok.source !== 'game' &&
5019
+ sold.key !== tok.key
5020
+ )
5021
+ warn(
5022
+ 'W-TOKEN-SALE',
5023
+ `${where}.prize`,
5024
+ `${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.`,
5025
+ 'Fine on devnet. Before mainnet, ask counsel.',
5026
+ );
4978
5027
  // One NFT, the last units of an item, a pool's prize: the winning entries share it in the order they are claimed.
4979
5028
  const scarce =
4980
5029
  cp &&
4981
5030
  (cp.gives === 'nft' || (cp.gives === 'item' && Number(m.item.get(cp.item)?.supply ?? 0) > 0) || prizePool(cp));
4982
- if (scarce)
5031
+ // A prize split by stake is set aside whole when the winner is named, or that decision is refused: no entry takes
5032
+ // another's part, but the pool must hold all of it by then.
5033
+ if (scarce && c.prize.split && prizePool(cp))
5034
+ warn(
5035
+ 'W-CONTEST-PRIZE',
5036
+ `${where}.prize`,
5037
+ `${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.`,
5038
+ '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.',
5039
+ );
5040
+ else if (scarce)
4983
5041
  warn(
4984
5042
  'W-CONTEST-PRIZE',
4985
5043
  `${where}.prize`,
@@ -47,11 +47,19 @@ export function setupCost(plan, economy) {
47
47
  const m = model(economy);
48
48
  // An account that grows (a live stash taking more NFTs) pays rent for its new bytes only.
49
49
  const rentOf = c => (c.grows ? BigInt(c.bytes) * RENT_PER_BYTE : rent(c.bytes));
50
+ // `fee`: what the program that makes an account keeps in it on top of its rent (Token Metadata's, for a token's name
51
+ // on chain). It is not rent: it never comes back.
50
52
  const accounts = plan.transactions
51
53
  .flatMap(t => t.steps)
52
54
  .flatMap(s => s.creates ?? [])
53
- .map(c => ({ ...c, lamports: rentOf(c).toString(), sol: sol(rentOf(c)) }));
55
+ .map(c => ({
56
+ ...c,
57
+ lamports: rentOf(c).toString(),
58
+ sol: sol(rentOf(c)),
59
+ ...(c.fee ? { feeSol: sol(BigInt(c.fee)) } : {}),
60
+ }));
54
61
  const rentLamports = accounts.reduce((n, a) => n + BigInt(a.lamports), 0n);
62
+ const metadataLamports = accounts.reduce((n, a) => n + BigInt(a.fee ?? 0), 0n);
55
63
  const charged = plan.transactions.flatMap(t => t.steps).filter(s => !NO_FEE_STEP.test(s.id));
56
64
  const feeLamports = BigInt(charged.length) * FEES.project;
57
65
  const networkLamports = plan.transactions.reduce((n, t) => n + BigInt(t.signers.length) * SIGNATURE_FEE, 0n);
@@ -76,7 +84,7 @@ export function setupCost(plan, economy) {
76
84
  for (const l of m.lotteries) if (steps.has(`rent:lottery:${l.key}`)) seedLamports += roundRentOf(l, m);
77
85
  for (const sc of m.schedules)
78
86
  if (sc.sealed && (sc.fundFrom ?? 'wallet') === 'wallet') putIn(sc.currency, sc.fund, `fund:${sc.key}`);
79
- const total = rentLamports + feeLamports + networkLamports + seedLamports;
87
+ const total = rentLamports + metadataLamports + feeLamports + networkLamports + seedLamports;
80
88
  const notes = [];
81
89
  if (accounts.some(a => a.approx))
82
90
  notes.push('Accounts marked “about” are sized roughly (a Core collection or an external token account).');
@@ -96,6 +104,7 @@ export function setupCost(plan, economy) {
96
104
  return {
97
105
  accounts,
98
106
  rentSol: sol(rentLamports),
107
+ metadataFeesSol: sol(metadataLamports),
99
108
  programFeesSol: sol(feeLamports),
100
109
  networkFeesSol: sol(networkLamports),
101
110
  seeds,
@@ -483,6 +492,8 @@ export function explainCost(c) {
483
492
  ];
484
493
  lines.push(
485
494
  ` rent ${s.rentSol} SOL for ${s.accounts.length} accounts; program fees ${s.programFeesSol} SOL; network fees ${s.networkFeesSol} SOL${
495
+ s.metadataFeesSol ? `; Token Metadata’s fees ${s.metadataFeesSol} SOL (a token’s name on chain)` : ''
496
+ }${
486
497
  Object.keys(s.seeds).length
487
498
  ? `; seeds ${Object.entries(s.seeds)
488
499
  .map(([k, v]) => `${v} ${k}`)
@@ -490,7 +501,10 @@ export function explainCost(c) {
490
501
  : ''
491
502
  }.`,
492
503
  );
493
- for (const a of s.accounts) lines.push(` ${a.approx ? 'about ' : ''}${a.sol} SOL: ${a.what} (${a.bytes} bytes)`);
504
+ for (const a of s.accounts)
505
+ lines.push(
506
+ ` ${a.approx ? 'about ' : ''}${a.sol} SOL: ${a.what} (${a.bytes} bytes)${a.feeSol ? `, and ${a.feeSol} SOL Token Metadata keeps for it` : ''}`,
507
+ );
494
508
  lines.push('While it runs (for a player, quote the “in all” number: it is what leaves their wallet for good):');
495
509
  for (const r of c.actions) {
496
510
  const deposits = r.deposits.map(d => `${d.sol} SOL for ${d.what} (back: ${d.back})`).join('; ');
@@ -204,6 +204,18 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
204
204
  );
205
205
  if (economy.prompt) lines.push(`Asked for: “${economy.prompt}”`);
206
206
 
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.
209
+ section(
210
+ 'The tokens going live makes',
211
+ [...m.tokens.values()]
212
+ .filter(t => t.source === 'game' && !economy.live?.mints?.[t.key])
213
+ .map(
214
+ 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
+ ),
217
+ );
218
+
207
219
  const fee = num(sim.fees.playerSol);
208
220
  const actions = [];
209
221
  for (const it of sim.items) {
@@ -559,6 +571,8 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
559
571
  lines.push('', 'What setting it up costs');
560
572
  lines.push(
561
573
  `- About ${num(s.totalSol)} SOL in all, in ${plural(s.transactions, 'transaction')} (${plural(s.approvals, 'wallet approval')}): rent ${num(s.rentSol)} SOL for ${plural(s.accounts.length, 'account')}, program fees ${num(s.programFeesSol)} SOL, network fees ${num(s.networkFeesSol)} SOL${
574
+ s.metadataFeesSol ? `, Token Metadata’s fees ${num(s.metadataFeesSol)} SOL (a token’s name on chain)` : ''
575
+ }${
562
576
  Object.keys(s.seeds).length
563
577
  ? `, seeds ${Object.entries(s.seeds)
564
578
  .map(([k, v]) => `${num(v)} ${k}`)
@@ -589,7 +603,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
589
603
  );
590
604
  for (const n of sim.notes) lines.push(`- ${n}`);
591
605
 
592
- const flagged = verdict.warnings.filter(w => w.code === 'W-CHANCE-PRIZE' || w.code === 'W-REVENUE-SHARE');
606
+ const flagged = verdict.warnings.filter(w => ['W-CHANCE-PRIZE', 'W-REVENUE-SHARE', 'W-TOKEN-SALE'].includes(w.code));
593
607
  const others = verdict.warnings.filter(w => !flagged.includes(w));
594
608
  if (flagged.length) section('Flagged for the law, once it is real money', [...new Set(flagged.map(w => w.message))]);
595
609
  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). 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, 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. `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 }]. */
@@ -1,7 +1,8 @@
1
1
  // The setup plan: every instruction that puts an economy live on loot, in order, built with the client and
2
2
  // packed into transactions that fit. Nothing here signs or talks to the chain. Pure.
3
3
  //
4
- // A step is { id, part, says, signers, creates: [{ what, bytes }], build(ctx) → instructions }. `ctx` names the
4
+ // A step is { id, part, says, signers, creates: [{ what, bytes, fee? }], build(ctx) → instructions } (`fee`: lamports
5
+ // the program that makes the account keeps in it, on top of its rent: Token Metadata's). `ctx` names the
5
6
  // addresses a step needs: { owner, authority, project, collection, tree, treasury, mints: { tokenKey: mint }, keys: {
6
7
  // name: address } } where `keys` are the fresh keys this setup makes ("new:collection", "new:tree", "new:mint/points"), each of
7
8
  // which signs the step that creates it. `placeholderContext` fills every address with a stand-in, so a plan can be
@@ -20,6 +21,7 @@ import {
20
21
  TOKEN_PROGRAM,
21
22
  } from '../chain/pda.js';
22
23
  import { createTableIx, extendTableIx } from '../chain/lookup.js';
24
+ import { createFungibleMetadataIx, METADATA_BYTES, METADATA_FEE, METADATA_LIMITS } from '../chain/metadata.js';
23
25
  import { MEMO_PROGRAM } from '../chain/token2022.js';
24
26
  import {
25
27
  computeUnitLimit,
@@ -732,18 +734,40 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
732
734
  const fresh = !live?.project;
733
735
 
734
736
  // Tokens the project mints: a classic mint with the owner as its authority, handed to the project right after.
737
+ // Each is named on chain in the same step (its Token Metadata record: the name, the ticker and the link wallets
738
+ // show), while the owner is still its mint authority: Token Metadata takes a record from nobody else, so a mint whose
739
+ // minting was handed on or turned off without one can never have one. The owner stays the record's update authority.
735
740
  for (const t of m.tokens.values()) {
736
741
  if (t.source !== 'game' || live?.mints?.[t.key] || (t.mint && !t.mint.startsWith('@'))) continue;
737
742
  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)`;
744
+ const record = { what: `the name of ${t.symbol} on chain`, bytes: METADATA_BYTES, fee: Number(METADATA_FEE) };
745
+ // 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
747
+ // planned as if it were new (explain's cost of the whole setup, which is never sent): its record is left out.
748
+ const fits = ['name', 'symbol', 'uri'].every(f => utf8(String(t[f] ?? '')).length <= METADATA_LIMITS[f]);
749
+ const name = ctx =>
750
+ fits
751
+ ? [
752
+ createFungibleMetadataIx({
753
+ mint: ctx.keys[key],
754
+ authority: ctx.owner,
755
+ name: t.name,
756
+ symbol: t.symbol,
757
+ uri: t.uri ?? '',
758
+ }),
759
+ ]
760
+ : [];
738
761
  if (t.fixedSupply) {
739
762
  const supply = amount(t.supply, t);
740
763
  add({
741
764
  id: `token:${t.key}`,
742
765
  part: `token:${t.key}`,
743
- says: `Make the token ${t.symbol} (${t.decimals} decimals) with a fixed supply of ${t.supply}, all of it to your wallet, then turn its minting off for good`,
766
+ says: `Make the token ${t.symbol} (${t.decimals} decimals) with a fixed supply of ${t.supply}, all of it to your wallet, ${named}, then turn its minting off for good`,
744
767
  signers: [owner, key],
745
768
  creates: [
746
769
  { what: `the mint of ${t.symbol}`, bytes: MINT_BYTES },
770
+ ...(fits ? [record] : []),
747
771
  { what: `your ${t.symbol} account`, bytes: 165 },
748
772
  ],
749
773
  build: ctx => [
@@ -755,6 +779,7 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
755
779
  owner: TOKEN_PROGRAM,
756
780
  }),
757
781
  initializeMint2Ix({ mint: ctx.keys[key], decimals: t.decimals, authority: ctx.owner }),
782
+ ...name(ctx),
758
783
  createAssociatedTokenAccountIdempotent(ctx.owner, ctx.owner, ctx.keys[key]),
759
784
  mintToIx({
760
785
  mint: ctx.keys[key],
@@ -770,9 +795,9 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
770
795
  add({
771
796
  id: `token:${t.key}`,
772
797
  part: `token:${t.key}`,
773
- says: `Make the token ${t.symbol} (${t.decimals} decimals), then hand its minting to the project`,
798
+ says: `Make the token ${t.symbol} (${t.decimals} decimals), ${named}, then hand its minting to the project`,
774
799
  signers: [owner, key],
775
- creates: [{ what: `the mint of ${t.symbol}`, bytes: MINT_BYTES }],
800
+ creates: [{ what: `the mint of ${t.symbol}`, bytes: MINT_BYTES }, ...(fits ? [record] : [])],
776
801
  build: ctx => [
777
802
  createAccountIx({
778
803
  from: ctx.owner,
@@ -782,6 +807,7 @@ export function setupSteps(economy, { now = 0, only = null, live: given = null }
782
807
  owner: TOKEN_PROGRAM,
783
808
  }),
784
809
  initializeMint2Ix({ mint: ctx.keys[key], decimals: t.decimals, authority: ctx.owner }),
810
+ ...name(ctx),
785
811
  setAuthorityIx({ account: ctx.keys[key], current: ctx.owner, newAuthority: ctx.project }),
786
812
  ],
787
813
  });
@@ -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).",
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.",
367
367
  "properties": {
368
368
  "key": {
369
369
  "$ref": "#/$defs/key"
@@ -401,11 +401,12 @@
401
401
  "description": "Where the token trades, recorded for the app and not read or enforced by the tools: loot doesn't launch or trade tokens. A token launched elsewhere is `existing`, by its mint. `games.fun` is the older spelling of `xgames.fun`."
402
402
  },
403
403
  "uri": {
404
- "$ref": "#/$defs/link"
404
+ "$ref": "#/$defs/link",
405
+ "description": "`project` only: a link to a JSON file that names the token's picture (`{ \"name\", \"symbol\", \"image\": \"https://…\" }`), at most 200 bytes. Going live writes it on chain with the token's name and ticker; without it wallets show the name and ticker and no picture."
405
406
  },
406
407
  "image": {
407
408
  "$ref": "#/$defs/link",
408
- "description": "A picture of it, recorded for the app: the tools don't read it, and the setup doesn't write it to the mint."
409
+ "description": "A picture of it, recorded for the app: the tools don't read it, and the setup doesn't write it on chain (what wallets show is the `image` the JSON file at `uri` names)."
409
410
  },
410
411
  "powers": {
411
412
  "type": "object",
package/js/mcp/tools.mjs CHANGED
@@ -5,7 +5,11 @@
5
5
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
6
6
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ import { addressBytes } from '../chain/base58.js';
9
+ import { metadataAddress } from '../chain/metadata.js';
10
+ import { parseMint, tokenEntry } from '../chain/mint.js';
8
11
  import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
12
+ import { mintState } from '../chain/token2022.js';
9
13
  import { decodeAny } from '../client/accounts.js';
10
14
  import { IDL } from '../client/ix.js';
11
15
  import { LOOT_PROGRAM, pda } from '../client/pda.js';
@@ -82,6 +86,7 @@ export const REMOTE_TOOLS = new Set([
82
86
  'xsolana_explain',
83
87
  'xsolana_review',
84
88
  'xsolana_status',
89
+ 'xsolana_token',
85
90
  'xsolana_client',
86
91
  ]);
87
92
 
@@ -688,6 +693,145 @@ export const tools = [
688
693
  return { ok: true, rows, text: text + footer };
689
694
  },
690
695
  },
696
+ {
697
+ name: 'xsolana_token',
698
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
699
+ description:
700
+ 'A token made elsewhere, as the chain has it, for an economy that uses it: give its mint’s address and get its ready `tokens` entry (`source: "existing"`) with its name, ticker, decimals and token program, and `powers` filled in: what the mint’s own keys can do (mint more, freeze, a transfer fee, a transfer hook, a permanent delegate, a pause…), in words too. Use it instead of asking the person to look the mint up. Reads devnet (or XSOLANA_RPC_URL): the mint and its name record only, never the link the record names.',
701
+ inputSchema: {
702
+ type: 'object',
703
+ properties: {
704
+ mint: { type: 'string', description: 'The token’s mint address.' },
705
+ key: {
706
+ type: 'string',
707
+ pattern: '^[a-z][a-z0-9-]{0,31}$',
708
+ description: 'What the spec will call it (default: its ticker, in the letters a key takes).',
709
+ },
710
+ project: {
711
+ type: 'string',
712
+ description:
713
+ 'The loot project’s address, when the token is one the project is to mint on a mint of its own: its mint authority then reads as the project’s, or as not handed over yet.',
714
+ },
715
+ },
716
+ required: ['mint'],
717
+ additionalProperties: false,
718
+ },
719
+ async handler(input, ctx) {
720
+ const mint = String(input.mint ?? '').trim();
721
+ try {
722
+ addressBytes(mint);
723
+ } catch {
724
+ return {
725
+ ok: false,
726
+ text: `${JSON.stringify(mint)} is not a Solana address. Give the token’s mint address.${footer}`,
727
+ };
728
+ }
729
+ // Devnet only: a mainnet URL is refused, and so is a chain whose first block is mainnet's or testnet's.
730
+ const rpc = (ctx?.rpc ?? rpcClient)(notMainnet(process.env.XSOLANA_RPC_URL || DEVNET_RPC));
731
+ if (!ctx?.rpc) await checkCluster(rpc);
732
+ let project = null;
733
+ if (input.project !== undefined) {
734
+ project = String(input.project).trim();
735
+ try {
736
+ addressBytes(project);
737
+ } catch {
738
+ return {
739
+ ok: false,
740
+ text: `${JSON.stringify(project)} is not a Solana address. Give the project’s address, or leave it out.${footer}`,
741
+ };
742
+ }
743
+ }
744
+ const [account, record] = await rpc.accounts([mint, metadataAddress(mint)]);
745
+ // A transfer fee changes on an epoch: with the chain's current one, a fee since lowered reads as it is now.
746
+ const epoch =
747
+ account && typeof rpc.call === 'function'
748
+ ? await rpc.call('getEpochInfo').then(
749
+ e => Number(e.epoch),
750
+ () => null,
751
+ )
752
+ : null;
753
+ const token = tokenEntry(mint, account, record?.data ?? null, {
754
+ ...(input.key ? { key: input.key } : {}),
755
+ project,
756
+ epoch,
757
+ });
758
+ if (!token)
759
+ return {
760
+ ok: false,
761
+ text: `${
762
+ account
763
+ ? `${mint} is not a token mint on ${CLUSTER} (a token account or a wallet is not the mint: an explorer’s token page names it)`
764
+ : `There is nothing at ${mint} on ${CLUSTER}. A coin launched on mainnet is not on ${CLUSTER}, where loot runs for now`
765
+ }.${footer}`,
766
+ };
767
+ if (token.source === 'sol')
768
+ return {
769
+ ok: true,
770
+ token,
771
+ text: `${mint} is wrapped SOL: in a spec that is SOL itself.\n\nIn the spec’s "tokens":\n${JSON.stringify(token)}${footer}`,
772
+ };
773
+ const read = parseMint(mint, account.owner, account.data, record?.data ?? null);
774
+ const supply = Number(BigInt(read.supply)) / 10 ** read.decimals;
775
+ const p = token.powers;
776
+ const state = mintState(account);
777
+ // The name and ticker are the token's own writing, anyone's to set: given as the entry has them (one line, cut
778
+ // to a spec's lengths), in quotes, as labels and never as words of this tool.
779
+ const called =
780
+ token.symbol || token.name
781
+ ? `calls itself ${[token.name && `“${token.name}”`, token.symbol && `ticker “${token.symbol}”`].filter(Boolean).join(', ')} (its own record’s words: a label anyone can write, not a fact)`
782
+ : 'has no name record, so wallets show it as an unknown token';
783
+ // What each power lets whoever holds it do, in the checker's words (check.mjs W-MINT-POWERS).
784
+ const can = [
785
+ p.mintAuthority === 'someone else' && `mint more of it whenever they want (${read.mintAuthority} can)`,
786
+ p.mintAuthority === 'project' && null,
787
+ p.freeze && 'freeze any account holding it, an economy’s pools and vaults included',
788
+ p.permanentDelegate && 'move or burn it out of any account',
789
+ p.transferHook && 'run a program of their own on every transfer, which can refuse one',
790
+ p.transferFeePercent > 0 && `keep ${p.transferFeePercent}% of every transfer`,
791
+ p.feeChangeable && 'raise its transfer fee, up to all of a transfer',
792
+ p.closeAuthority && 'close the mint and make it again with other powers',
793
+ p.pausable && 'pause every transfer of it',
794
+ p.defaultFrozen && 'have every new account of it start frozen',
795
+ !p.defaultFrozen && p.defaultState && p.freeze && 'switch every new account of it to start frozen',
796
+ ].filter(Boolean);
797
+ const also = [
798
+ p.nonTransferable && 'It cannot be transferred at all: nothing can be paid, staked or given as a prize in it.',
799
+ p.confidentialTransfers &&
800
+ 'Its holders can turn plain transfers into their account away (confidential transfers).',
801
+ state.paused && 'It is paused now: nothing of it moves until its issuer ends the pause.',
802
+ state.scaled &&
803
+ `Wallets show its amounts multiplied by a rate its mint carries${state.scaler ? ', which its issuer can change' : ''}: an economy counts the amounts under it, so a price in a spec is not the number a wallet shows.`,
804
+ p.mintAuthority === 'project' &&
805
+ 'Its mint authority is the project given: the project mints it, as its rules say.',
806
+ project &&
807
+ p.mintAuthority === 'someone else' &&
808
+ `Its mint authority is not the project given (${project}): until it is handed over, nothing the economy mints in it lands.`,
809
+ ].filter(Boolean);
810
+ const text = [
811
+ `The token at ${mint} on ${CLUSTER}: ${read.program === 'token-2022' ? 'a Token-2022' : 'a classic SPL'} token, ${plural(read.decimals, 'decimal')}, ${supply.toLocaleString('en-US')} in existence${p.mintAuthority === 'none' ? ', and nobody can mint more' : ''}. It ${called}.`,
812
+ can.length
813
+ ? `Whoever controls its mint can:\n${can.map(c => `- ${c}`).join('\n')}\nAn economy in it is only as safe as the token: say so where players pay, stake or are paid.`
814
+ : `Its mint has no key that can freeze it, take it, charge on it or stop a transfer${p.mintAuthority === 'project' ? ' (the project mints it)' : ''}.`,
815
+ ...also,
816
+ `In the spec’s "tokens" (xsolana_check then says what each power means for the economy’s parts):\n${JSON.stringify(token, null, 2)}`,
817
+ ].join('\n\n');
818
+ return {
819
+ ok: true,
820
+ token,
821
+ mint: {
822
+ address: mint,
823
+ program: read.program,
824
+ decimals: read.decimals,
825
+ supply: read.supply,
826
+ name: token.name ?? null,
827
+ symbol: token.symbol ?? null,
828
+ mintAuthority: read.mintAuthority,
829
+ freezeAuthority: read.freezeAuthority,
830
+ },
831
+ text: text + footer,
832
+ };
833
+ },
834
+ },
691
835
  {
692
836
  name: 'xsolana_client',
693
837
  annotations: READ,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "mcpName": "io.github.XGamesFun/solana-kit",
5
5
  "description": "Loot boxes, gacha, shops, lotteries, auctions, NFT staking, crafting and rewards on Solana without writing a smart contract: one shared program, its JS client, a CLI, and an MCP server and skill so Claude Code, Codex or any AI agent can design, check, simulate and launch an economy.",
6
6
  "license": "Apache-2.0",
package/release.json CHANGED
@@ -4,5 +4,5 @@
4
4
  "sbfBytes": 1783800,
5
5
  "sbfSha256": "af318ccfa4d2901076b85fcd1b4467b03253adf0de16e73a9dfc1ef8077b2c14",
6
6
  "idlSha256": "0e88fc826a585cf17917cbc654e33b68232771769fe9aae1d5899a257940ff47",
7
- "clientSha256": "01d85ff69999efc8b0641ce684dd060cf0ad421a8e125f91d620fef50bb5aedd"
7
+ "clientSha256": "8c8c89bfca22961d24379b2cc63964dfa2e68d3b65cdcbb33d52352677464533"
8
8
  }