@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.
package/README.md CHANGED
@@ -64,7 +64,7 @@ codex plugin add xsolana@xgamesfun
64
64
 
65
65
  **claude.ai, ChatGPT or Claude Desktop:** add `https://xgames.fun/mcp` as a custom connector. Nothing to install.
66
66
 
67
- **Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.26.2 xsolana mcp`.
67
+ **Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.26.4 xsolana mcp`.
68
68
 
69
69
  **Any agent that reads skills** (Copilot, Gemini CLI, Windsurf…): `npx skills add XGamesFun/solana-kit`.
70
70
 
@@ -75,19 +75,22 @@ are in [docs/harnesses.md](docs/harnesses.md).
75
75
 
76
76
  ```bash
77
77
  # find a worked example close to your idea
78
- npx -y @xgamesfun/solana@0.26.2 examples shop with loot boxes
78
+ npx -y @xgamesfun/solana@0.26.4 examples shop with loot boxes
79
+
80
+ # using a coin you already launched? read it from the chain into its "tokens" entry
81
+ npx -y @xgamesfun/solana@0.26.4 token <its mint address>
79
82
 
80
83
  # does it work? where does the money go? what does it cost?
81
- npx -y @xgamesfun/solana@0.26.2 check xsolana.json
82
- npx -y @xgamesfun/solana@0.26.2 simulate xsolana.json
83
- npx -y @xgamesfun/solana@0.26.2 cost xsolana.json
84
+ npx -y @xgamesfun/solana@0.26.4 check xsolana.json
85
+ npx -y @xgamesfun/solana@0.26.4 simulate xsolana.json
86
+ npx -y @xgamesfun/solana@0.26.4 cost xsolana.json
84
87
 
85
88
  # the whole economy in plain words, and what a cheater could get
86
- npx -y @xgamesfun/solana@0.26.2 explain xsolana.json
87
- npx -y @xgamesfun/solana@0.26.2 review xsolana.json
89
+ npx -y @xgamesfun/solana@0.26.4 explain xsolana.json
90
+ npx -y @xgamesfun/solana@0.26.4 review xsolana.json
88
91
 
89
92
  # go live, approved in your own wallet
90
- npx -y @xgamesfun/solana@0.26.2 approve xsolana.json
93
+ npx -y @xgamesfun/solana@0.26.4 approve xsolana.json
91
94
  ```
92
95
 
93
96
  Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
package/bin/xsolana.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // xsolana guide [part] [--json] what an economy can be made of, and what loot cannot do
8
8
  // xsolana examples [words…] [--json] the nearest worked examples and rated economies
9
9
  // xsolana check|simulate|cost|explain|review|publish|status [file] [--json] one tool on a spec file (default xsolana.json)
10
+ // xsolana token <mint> [--json] a token made elsewhere, read from the chain: its `tokens` entry, powers and all
10
11
  // xsolana approve [file] [--port n] [--no-open] open the local page where the owner approves going live in their wallet
11
12
  // xsolana connect [--agent claude|codex] [--scope user|project] [--local] [--remove] [--dry-run] register the MCP server and the skill
12
13
  // xsolana tool --list | xsolana tool <name> '<json>' [--args-file f] [--json] any tool by name
@@ -30,13 +31,14 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
30
31
  xsolana publish [file] what going live does, and how to approve it
31
32
  xsolana approve [file] approve going live in your own wallet (a local page; --port n, --no-open)
32
33
  xsolana status [file] a live economy on chain
34
+ xsolana token <mint> a token made elsewhere, as the chain has it: its "tokens" entry, with what its mint can do
33
35
  xsolana connect add the xsolana tools to Claude Code or Codex (--agent claude|codex, --scope user|project, --local, --remove, --dry-run)
34
36
  xsolana mcp serve the tools over MCP (stdio)
35
37
  xsolana mcp --http serve the hosted tools over HTTP at http://127.0.0.1:8787/mcp (--port n, --host h)
36
38
  xsolana tool --list every tool; xsolana tool <name> '<json>' runs one (--args-file f)
37
39
  xsolana version this package's xsolana version and program
38
40
 
39
- A tool's command (guide, examples, check … status, tool) takes --json for its whole answer as JSON.
41
+ A tool's command (guide, examples, check … status, token, tool) takes --json for its whole answer as JSON.
40
42
  review exits 1 when it finds something high.`;
41
43
 
42
44
  /** A failure the person caused (a bad flag, a missing file): said in one line, without a stack. */
@@ -57,6 +59,7 @@ const FLAGS = {
57
59
  review: TOOL_FLAGS,
58
60
  publish: TOOL_FLAGS,
59
61
  status: TOOL_FLAGS,
62
+ token: TOOL_FLAGS,
60
63
  approve: ['--port', '--no-open'],
61
64
  connect: ['--agent', '--scope', '--local', '--remove', '--dry-run'],
62
65
  tool: ['--list', '--args-file', ...TOOL_FLAGS],
@@ -146,6 +149,10 @@ async function main(argv) {
146
149
  case 'status':
147
150
  await tool(`xsolana_${command}`, fileInput());
148
151
  break;
152
+ case 'token':
153
+ if (!positional[0]) throw new UsageError('xsolana token <mint>: give the token’s mint address.');
154
+ await tool('xsolana_token', { mint: positional[0] });
155
+ break;
149
156
  case 'approve': {
150
157
  const port = portOf(values.get('--port'));
151
158
  const { approve } = await import('../js/approve/server.mjs');
@@ -17,7 +17,8 @@
17
17
  "powers": {
18
18
  "mintAuthority": "someone else",
19
19
  "freeze": false,
20
- "transferFeePercent": 1
20
+ "transferFeePercent": 1,
21
+ "feeChangeable": true
21
22
  }
22
23
  }
23
24
  ],
@@ -0,0 +1,100 @@
1
+ {
2
+ "version": 2,
3
+ "network": "devnet",
4
+ "name": "Community Coin",
5
+ "prompt": "Our own coin, EMBER: 100 million, a fixed supply, no more ever. 10 million go to a week-long presale, split by what each backer put in, and everyone is refunded if it raises under 50 SOL. 24 million vest to the team: nothing for three months, then 2 million a month for a year, and nobody can stop it. 30 million pay the people who stake it, a little every day. We keep the rest.",
6
+ "currency": "sol",
7
+ "tokens": [
8
+ {
9
+ "key": "sol",
10
+ "source": "sol"
11
+ },
12
+ {
13
+ "key": "ember",
14
+ "source": "project",
15
+ "name": "Ember",
16
+ "symbol": "EMBER",
17
+ "decimals": 6,
18
+ "supply": 100000000,
19
+ "uri": "https://example.com/ember.json"
20
+ }
21
+ ],
22
+ "collection": false,
23
+ "pools": [
24
+ {
25
+ "key": "presale",
26
+ "currency": "ember",
27
+ "seed": 10000000,
28
+ "about": "What the presale's backers split."
29
+ },
30
+ {
31
+ "key": "rewards",
32
+ "currency": "ember",
33
+ "seed": 30000000,
34
+ "about": "What stakers are paid from, a little every day."
35
+ }
36
+ ],
37
+ "contests": [
38
+ {
39
+ "key": "raise",
40
+ "name": "The presale",
41
+ "currency": "sol",
42
+ "sides": 1,
43
+ "sideNames": ["Back it"],
44
+ "entriesClose": "7d",
45
+ "resultDue": "8d",
46
+ "minPot": 50,
47
+ "split": [
48
+ {
49
+ "to": "treasury",
50
+ "percent": 100
51
+ }
52
+ ],
53
+ "prize": {
54
+ "gives": "tokens",
55
+ "amount": 10000000,
56
+ "from": "pool:presale",
57
+ "currency": "ember",
58
+ "split": true
59
+ }
60
+ }
61
+ ],
62
+ "schedules": [
63
+ {
64
+ "key": "team",
65
+ "name": "Team vesting",
66
+ "does": "pay",
67
+ "sealed": true,
68
+ "currency": "ember",
69
+ "to": "wallet:5DckzByxgf3nMQ1m1SGfpsnRXozawiFncTBZDtZ9Nj7T",
70
+ "amount": 2000000,
71
+ "start": "publish+90d",
72
+ "every": "30d",
73
+ "maxRuns": 12,
74
+ "fund": 24000000
75
+ },
76
+ {
77
+ "key": "drip",
78
+ "name": "Stakers' drip",
79
+ "does": "pay",
80
+ "from": "rewards",
81
+ "to": "farm:hearth",
82
+ "percent": 1,
83
+ "every": "1d"
84
+ }
85
+ ],
86
+ "farms": [
87
+ {
88
+ "key": "hearth",
89
+ "name": "The hearth",
90
+ "stakes": "ember",
91
+ "rewards": "ember",
92
+ "tiers": [
93
+ {
94
+ "lock": "7d",
95
+ "multiplier": 1
96
+ }
97
+ ]
98
+ }
99
+ ]
100
+ }
@@ -5,10 +5,14 @@
5
5
  // the tree the file calls made must be on chain and be that project's.
6
6
  import { encodeBase58 } from '../chain/base58.js';
7
7
  import { parseCoreCollection, MPL_CORE_PROGRAM } from '../chain/core.js';
8
+ import { utf8 } from '../chain/bytes.js';
9
+ import { METADATA_LIMITS, metadataAddress, parseRecord } from '../chain/metadata.js';
8
10
  import { parseMint } from '../chain/mint.js';
11
+ import { mintState, powersOf } from '../chain/token2022.js';
9
12
  import { accountKind, decode } from '../client/accounts.js';
10
13
  import { BUBBLEGUM_PROGRAM, LOOT_PROGRAM, pda } from '../client/pda.js';
11
- import { SYSTEM_PROGRAM } from '../chain/pda.js';
14
+ import { METAPLEX_METADATA_PROGRAM, SYSTEM_PROGRAM } from '../chain/pda.js';
15
+ import { tokenLabels } from '../economy/model.mjs';
12
16
  import { madeSoFar } from '../economy/plan.mjs';
13
17
 
14
18
  /** A `live` section that does not match the chain: the message says what differs, in plain words. */
@@ -115,18 +119,216 @@ export async function checkLive({ live, m, wallet, chain }) {
115
119
  return { owner, project };
116
120
  }
117
121
 
122
+ /**
123
+ * A record's own words, to show: whoever holds the record wrote them, so they are put on one line, cut short, with
124
+ * nothing in them that hides, reorders or closes a quote (controls, marks that turn text around or take no room, line
125
+ * breaks, quote marks), and are only ever shown inside quotes.
126
+ */
127
+ const HIDES = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}"“”„‟«»]/gu;
128
+ const clip = text => [...String(text).replace(HIDES, '')].slice(0, 40).join('');
129
+
130
+ /**
131
+ * The tokens whose name record this approval writes again, read from the chain: `renames`, their keys; `notes`, in
132
+ * words, for a name the spec changes that can't be written; and `held`, the keys of those. A token the setup made
133
+ * (`live.mints`) is called on chain what its record says. Its spec's name, ticker or link is written there again when
134
+ * - the spec gives them otherwise than it was published with (`published`: the file's own snapshot; with none, a
135
+ * setup cut short, the chain alone is the measure, so a name edited before a resume is written), and
136
+ * - the record says something else, is the mint's, is the project owner's to change (its update authority, and
137
+ * still changeable) and holds nothing a write of its words would take away (a royalty, creators, a collection,
138
+ * uses: `parseRecord`'s `plain`).
139
+ * A token with no record (one that went live before setups named tokens), or a record handed on, made unchangeable
140
+ * or given a royalty or creators outside these tools, is not written: said in `notes`, and nothing else is held up.
141
+ * A name so held is not recorded as published (approve/server.mjs), so it is said again at each approve until the
142
+ * spec gives the old name back. Nothing here trusts the file for what is on chain. `owner`: the project's owner.
143
+ */
144
+ export async function readRenames({ live, m, chain, owner, published = null }) {
145
+ const out = { renames: [], notes: [], held: [], settled: [] };
146
+ const made = madeSoFar(live) ?? {};
147
+ // A name this setup or change wrote already, or sent and has not seen land (the file is still `partial`): the
148
+ // record no longer says what was published, so the published spec is no measure of it and the chain is read.
149
+ const step = key => `name:${key}`;
150
+ const written = key =>
151
+ Boolean(live?.partial) &&
152
+ ((live.landed ?? []).includes(step(key)) || (live.pending ?? []).some(p => (p.steps ?? []).includes(step(key))));
153
+ if (typeof chain.account !== 'function') return out;
154
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
155
+ const hold = (key, note) => {
156
+ out.held.push(key);
157
+ out.notes.push(note);
158
+ };
159
+ for (const [key, address] of Object.entries(made.mints ?? {})) {
160
+ const t = m.tokens.get(key);
161
+ if (t?.source !== 'game' || (t.mint && !t.mint.startsWith('@'))) continue;
162
+ const want = [t.name, t.symbol, t.uri ?? ''];
163
+ const was = published ? (published.tokens ?? []).find(x => x.key === key) : null;
164
+ const said = was ? tokenLabels(was) : null;
165
+ // as it was published: nothing asked for, and a record its owner changed elsewhere is left as it is
166
+ const asPublished = Boolean(said) && same(said, want);
167
+ if (asPublished && !written(key)) continue;
168
+ // (the token as the person knows it: by the ticker it was published with)
169
+ const what = `the token ${said?.[1] ?? t.symbol ?? key}`;
170
+ const again = 'This is said at each approve until the spec gives the old name, ticker and link back.';
171
+ const account = await chain.account(metadataAddress(address));
172
+ const record = account?.owner === METAPLEX_METADATA_PROGRAM ? parseRecord(account.data) : null;
173
+ if (!record || record.mint !== address) {
174
+ if (said && !asPublished)
175
+ hold(
176
+ key,
177
+ account?.owner === METAPLEX_METADATA_PROGRAM
178
+ ? `The spec changes what ${what} is called, but the name record of its mint ${address} can’t be read as one: nothing is sent for it. ${again}`
179
+ : `The spec changes what ${what} is called, but it went live before setups named tokens on chain: it has no name record, and none can be made now (only a mint’s own authority makes one, and its minting was handed on or turned off). Nothing is sent for it, and wallets keep showing it as before. ${again}`,
180
+ );
181
+ continue;
182
+ }
183
+ if (same([record.name, record.symbol, record.uri], want)) {
184
+ // the chain says the spec's name already (written by another tool, or by a session whose file was lost): it is
185
+ // the published one from here on
186
+ if (said && !asPublished) out.settled.push(key);
187
+ continue;
188
+ }
189
+ const called = `“${clip(record.name)}” (“${clip(record.symbol)}”)`;
190
+ const kept = `nothing is sent for it, and wallets keep showing ${called}. ${said ? again : ''}`.trim();
191
+ if (record.updateAuthority !== owner)
192
+ hold(
193
+ key,
194
+ `The spec changes what ${what} is called, but its name record is no longer the project owner’s to change (it was handed to another wallet, outside these tools): ${kept}`,
195
+ );
196
+ else if (!record.mutable)
197
+ hold(
198
+ key,
199
+ `The spec changes what ${what} is called, but its name record can no longer be changed (it was made unchangeable, outside these tools): ${kept}`,
200
+ );
201
+ else if (!record.plain)
202
+ hold(
203
+ key,
204
+ `The spec changes what ${what} is called, but its name record was given ${[record.royalty ? 'a royalty' : null, record.creators ? 'creators' : null, record.collection ? 'a collection' : null, record.uses ? 'uses' : null].filter(Boolean).join(', ')} outside these tools, which writing its name from here would take away or be refused for: ${kept} Change its name with the tool that set those.`,
205
+ );
206
+ // (the checker holds a changed name to what a record takes; a setup cut short has no published spec to say it
207
+ // changed, so it is held to it here)
208
+ else if (
209
+ want.some(
210
+ (v, i) => utf8(String(v)).length > [METADATA_LIMITS.name, METADATA_LIMITS.symbol, METADATA_LIMITS.uri][i],
211
+ ) ||
212
+ !String(t.name).trim() ||
213
+ !String(t.symbol).trim()
214
+ )
215
+ hold(
216
+ key,
217
+ `The spec changes what ${what} is called to words a name record can’t take (a name of at most ${METADATA_LIMITS.name} bytes, a ticker of at most ${METADATA_LIMITS.symbol}, a link of at most ${METADATA_LIMITS.uri}, and neither name nor ticker blank): ${kept}`,
218
+ );
219
+ else out.renames.push(key);
220
+ }
221
+ return out;
222
+ }
223
+
118
224
  /** A token the spec names by its mint address that is not that mint on chain: the message says what differs. */
119
225
  export class MintMismatch extends Error {}
120
226
 
227
+ /** What each power of a mint's own keys lets its holder do, in the checker's words (check.mjs W-MINT-POWERS). */
228
+ const POWER_SAYS = {
229
+ freeze: 'freeze any account holding it, the economy’s pools and vaults included',
230
+ permanentDelegate: 'move or burn it out of any account',
231
+ transferHook: 'run a program of their own on every transfer, which can refuse one',
232
+ feeChangeable: 'raise its transfer fee, up to all of a transfer',
233
+ closeAuthority: 'close the mint and make it again with other powers',
234
+ pausable: 'pause every transfer of it',
235
+ defaultFrozen: 'have every new account of it start frozen',
236
+ defaultState: 'switch every new account of it to start frozen',
237
+ };
238
+ /** What the mint itself does, whoever controls it: said as a sentence of its own. */
239
+ const MINT_SAYS = {
240
+ nonTransferable: 'It cannot be transferred at all: nothing can be paid, staked or given as a prize in it.',
241
+ confidentialTransfers: 'Its holders can turn plain transfers into their account away (confidential transfers).',
242
+ };
243
+ /** What a spec says of a power when it denies it, for the refusal. */
244
+ const POWER_NAMES = {
245
+ freeze: 'no freeze authority',
246
+ permanentDelegate: 'no permanent delegate',
247
+ transferHook: 'no transfer hook',
248
+ feeChangeable: 'a transfer fee nobody can change',
249
+ closeAuthority: 'no close authority',
250
+ pausable: 'no pause',
251
+ defaultFrozen: 'accounts that don’t start frozen',
252
+ defaultState: 'no default account state',
253
+ nonTransferable: 'a token that can be transferred',
254
+ confidentialTransfers: 'no confidential transfers',
255
+ };
256
+
257
+ /**
258
+ * A named mint's powers as the chain has them (`onChain`: token2022.js `powersOf`) against what the spec says of them
259
+ * (`said`: its `powers`, or nothing). The spec denying a power the mint has is a contradiction: the checker's warnings
260
+ * were worked out from the spec, so the person read that a risk was not there (throws MintMismatch). A power the spec
261
+ * is silent on is not refused (a spec written before the tools could read a mint names none): it is returned in words,
262
+ * for the page to show before the owner approves. → the sentence, or null when the spec says all the chain has.
263
+ */
264
+ function powersAgainst(t, what, onChain, said = {}, { state = {}, project = null } = {}) {
265
+ const fix = `Run \`xsolana token ${t.mint}\` and write its "powers" as the chain has them.`;
266
+ for (const [power, name] of Object.entries(POWER_NAMES))
267
+ if (said[power] === false && onChain[power])
268
+ throw new MintMismatch(
269
+ `The spec says ${what} has ${name} ("${power}": false); on chain, ${POWER_SAYS[power] ? `whoever controls its mint ${t.mint} can ${POWER_SAYS[power]}` : `its mint ${t.mint} says otherwise`}. ${fix}`,
270
+ );
271
+ if (said.mintAuthority === 'none' && onChain.mintAuthority !== 'none')
272
+ throw new MintMismatch(
273
+ `The spec says nobody can mint more of ${what} ("mintAuthority": "none"); on chain its mint ${t.mint} has a mint authority. ${fix}`,
274
+ );
275
+ const fee = onChain.transferFeePercent ?? 0;
276
+ if (said.transferFeePercent !== undefined && fee > Number(said.transferFeePercent))
277
+ throw new MintMismatch(
278
+ `The spec says ${what} keeps ${said.transferFeePercent}% of every transfer; on chain its mint ${t.mint} keeps ${fee}%, so pools, prizes and stakes receive less than the page shows. ${fix}`,
279
+ );
280
+ // A default state only matters with someone who can freeze: with no freeze authority nobody can switch it.
281
+ const can = power => (power === 'defaultState' ? !onChain.defaultFrozen && onChain.freeze : true);
282
+ const unsaid = [
283
+ onChain.mintAuthority === 'someone else' &&
284
+ said.mintAuthority === undefined &&
285
+ 'mint more of it whenever they want',
286
+ ...Object.entries(POWER_SAYS).map(
287
+ ([power, says]) => onChain[power] && said[power] === undefined && can(power) && says,
288
+ ),
289
+ fee > 0 && said.transferFeePercent === undefined && `keep ${fee}% of every transfer`,
290
+ ].filter(Boolean);
291
+ const lines = [];
292
+ if (unsaid.length)
293
+ lines.push(
294
+ `whoever controls the mint of ${what} (${t.mint}) can ${unsaid.join('; ')}. The checker’s warnings did not count it`,
295
+ );
296
+ for (const [power, says] of Object.entries(MINT_SAYS))
297
+ if (onChain[power] && said[power] === undefined) lines.push(says.replace(/\.$/, ''));
298
+ // The spec says the project mints it: true only once the mint's authority is the project. Until then whatever the
299
+ // economy mints in it fails, and whoever holds the authority mints as they like.
300
+ if (['project', 'game'].includes(said.mintAuthority) && onChain.mintAuthority !== 'project')
301
+ lines.push(
302
+ onChain.mintAuthority === 'none'
303
+ ? `the spec says the project mints ${what}, and on chain nobody can mint it (its mint ${t.mint} has no authority, for good): everything this economy would mint in it will fail`
304
+ : `the spec says the project mints ${what}, and on chain its mint ${t.mint} is someone else’s to mint${project ? `: until its authority is handed to the project (${project}), what this economy mints in it fails, and its holder can mint more` : ', not the project’s yet'}`,
305
+ );
306
+ if (state.paused) lines.push(`${what} is paused now: nothing of it moves until its issuer ends the pause`);
307
+ if (state.scaled)
308
+ lines.push(
309
+ `wallets show amounts of ${what} multiplied by a rate its mint carries${state.scaler ? ', which its issuer can change' : ''}: the economy counts the amounts under it, so a price here is not the number a wallet shows`,
310
+ );
311
+ return lines.length
312
+ ? `Read from the chain, and not in the spec: ${lines.join('. ')}. An economy in it is only as safe as the token.`
313
+ : null;
314
+ }
315
+
121
316
  /**
122
317
  * Checks every token the spec names by its mint address (an existing token, or a game token on a mint of its own)
123
318
  * against `chain` ({ account }): the mint must be there, of the token program the spec says, with the decimals the
124
319
  * spec says. Every amount the page shows is written in base units with the spec's decimals, and the program takes them
125
320
  * as they are, so one decimal off is ten times what the page says. A mint the setup makes is made with the spec's
126
- * decimals, and is not read. Throws MintMismatch.
321
+ * decimals, and is not read. What the mint's own keys can do is read too (`powersAgainst`): a power the spec denies
322
+ * and the mint has is refused, and one the spec is silent on is returned for the page to show. Throws MintMismatch.
323
+ * `project`: the project's address (the wallet's own, or the live one), which a mint the project mints must name as
324
+ * its authority. `chain.epoch`, when the chain has it, tells a transfer fee since lowered from one still in force.
325
+ * → { powers: [{ key, mint, says }] }, one for each named token with something its spec doesn't say.
127
326
  */
128
- export async function checkMints({ m, chain }) {
327
+ export async function checkMints({ m, chain, project = null }) {
129
328
  const named = [...m.tokens.values()].filter(t => t.mint && !t.mint.startsWith('@'));
329
+ const powers = [];
330
+ let epoch = null;
331
+ if (named.some(t => !t.wsol) && typeof chain.epoch === 'function') epoch = await chain.epoch().catch(() => null);
130
332
  for (const t of named) {
131
333
  const what = `the token ${t.symbol ?? t.key} (“${t.key}”)`;
132
334
  if (t.wsol) {
@@ -151,8 +353,20 @@ export async function checkMints({ m, chain }) {
151
353
  throw new MintMismatch(
152
354
  `The spec says ${what} is a ${t.program} token; its mint ${t.mint} is ${mint.program}. Write "program": "${mint.program}".`,
153
355
  );
356
+ let onChain, state;
357
+ try {
358
+ onChain = powersOf(account, { project, epoch }) ?? {};
359
+ state = mintState(account);
360
+ } catch {
361
+ throw new MintMismatch(
362
+ `The mint ${t.mint} of ${what} does not read as a mint: its data is not what its token program writes.`,
363
+ );
364
+ }
365
+ const says = powersAgainst(t, what, onChain, t.powers, { state, project });
366
+ if (says) powers.push({ key: t.key, mint: t.mint, says });
154
367
  }
155
368
  await checkTreasury({ m, chain });
369
+ return { powers };
156
370
  }
157
371
 
158
372
  /** Programs whose own accounts are often pasted where their vault was meant: a multisig's or a DAO's account. */
@@ -208,7 +208,7 @@
208
208
  </section>
209
209
 
210
210
  <section class="panel" id="warnbox" hidden>
211
- <b>Before you approve</b>
211
+ <b id="warnhead">Before you approve</b>
212
212
  <ul class="warn" id="warnings"></ul>
213
213
  </section>
214
214
 
@@ -60,11 +60,23 @@ export function approvalPage(token) {
60
60
 
61
61
  // The last state shown: ticking the legal box shows it again, with Approve enabled or not.
62
62
  let last = null;
63
+ // The legal lines the box was last shown with.
64
+ let legalShown = new Set();
63
65
  function render(state) {
64
66
  last = state;
65
- $('title').textContent = state.done ? `“${state.name}” is live` : `Approve “${state.name}”`;
67
+ // A token's name the spec changes and the chain can't take (`unwritten`): said whether or not anything else was
68
+ // sent. With nothing else to send, the page never calls that a landing.
69
+ const unwritten = state.unwritten ?? [];
70
+ const nothingSent = state.done && unwritten.length > 0 && !state.signatures?.length;
71
+ $('title').textContent = state.done
72
+ ? nothingSent
73
+ ? `Nothing to send for “${state.name}”`
74
+ : `“${state.name}” is live`
75
+ : `Approve “${state.name}”`;
66
76
  $('sub').textContent = state.done
67
- ? `Every step landed on devnet. ${state.file} now holds the project’s addresses: you can close this page.`
77
+ ? nothingSent
78
+ ? `The only change in ${state.file} is a token’s name, and it can’t be written on chain: nothing was sent, and the file is as it was. See below.`
79
+ : `Every step landed on devnet. ${state.file} now holds the project’s addresses: you can close this page.`
68
80
  : `${state.mode === 'change' ? 'A change to a live economy' : state.mode === 'resume' ? 'A setup that was cut short goes on' : 'A new economy'} from ${state.file}, on the shared loot program ${state.program.slice(0, 6)}…${state.project ? `, in the project ${state.project}${state.wallet ? `, made from your wallet ${state.wallet}` : ''}` : ''}`;
69
81
  $('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
70
82
  const steps = $('steps');
@@ -89,29 +101,43 @@ export function approvalPage(token) {
89
101
  return li;
90
102
  }),
91
103
  );
92
- const warnings = state.warnings ?? [];
93
- $('warnbox').hidden = !warnings.length || state.done;
104
+ // Once done, what was to be read before approving is past; what could not be written still stands.
105
+ const warnings = state.done ? unwritten : (state.warnings ?? []);
106
+ $('warnbox').hidden = !warnings.length;
107
+ $('warnhead').textContent = state.done ? 'Not written' : 'Before you approve';
94
108
  $('warnings').replaceChildren(
95
109
  ...warnings.map(w => Object.assign(document.createElement('li'), { textContent: w })),
96
110
  );
97
111
  // The notice (no warranty, at the owner's own risk, legal where it runs) and this economy's legal flags, in a
98
112
  // block of their own whatever the agent said: Approve waits until the owner ticks that they read them.
99
113
  const legal = state.legal ?? [];
114
+ // A line that was not there when the box was ticked (what the chain says of a token, read once a wallet connects)
115
+ // has not been read: the box is cleared, and Approve waits for it again.
116
+ const lines = legal.map(f => f.says);
117
+ if (lines.some(l => !legalShown.has(l))) $('legalok').checked = false;
118
+ legalShown = new Set(lines);
100
119
  $('legalbox').hidden = Boolean(state.done);
101
120
  $('legal').hidden = !legal.length;
102
121
  $('legal').replaceChildren(...legal.map(f => Object.assign(document.createElement('li'), { textContent: f.says })));
103
122
  const unread = !$('legalok').checked;
123
+ // The state on the page is the connected wallet's own only once the server has read the chain for it: what it
124
+ // says of the tokens comes with that. Until then (the read is on its way, or failed) nothing can be approved.
125
+ const reading = Boolean(account) && !state.done && !state.refused && state.wallet !== account.address;
104
126
  // A live section the chain does not bear out: nothing can be approved.
105
- $('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread;
127
+ $('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread || reading;
106
128
  $('status').textContent = state.done
107
- ? 'Done.'
129
+ ? nothingSent
130
+ ? 'Nothing to approve.'
131
+ : 'Done.'
108
132
  : state.refused
109
133
  ? 'Nothing can be approved: see above.'
110
134
  : !account
111
135
  ? 'Connect a wallet first.'
112
- : unread
113
- ? 'Read the notice above and tick that you have.'
114
- : `${state.remaining} transaction${state.remaining === 1 ? '' : 's'} to go.`;
136
+ : reading
137
+ ? 'Reading the chain for your wallet…'
138
+ : unread
139
+ ? 'Read the notice above and tick that you have.'
140
+ : `${state.remaining} transaction${state.remaining === 1 ? '' : 's'} to go.`;
115
141
  }
116
142
 
117
143
  async function refresh() {
@@ -159,7 +185,10 @@ export function approvalPage(token) {
159
185
  }
160
186
 
161
187
  $('legalok').addEventListener('change', () => {
162
- if (last) render(last);
188
+ if (!last) return;
189
+ // The wallet's own state never arrived (its read failed): asked again, so the box is ticked on what it shows.
190
+ if (account && !last.done && last.wallet !== account.address) refresh();
191
+ else render(last);
163
192
  });
164
193
 
165
194
  // One wallet is connected at once; with several in the browser, the person picks one from a list.
@@ -201,7 +230,8 @@ export function approvalPage(token) {
201
230
  const signer = account;
202
231
  const signingWallet = wallet;
203
232
  try {
204
- const batch = await api('/api/prepare', { wallet: signer.address });
233
+ // `read`: the lines about its tokens this page showed when the box was ticked (the server gives no batch for others)
234
+ const batch = await api('/api/prepare', { wallet: signer.address, read: last?.read ?? null });
205
235
  const details = (batch.details ?? []).flat();
206
236
  log(
207
237
  `Asking your wallet to approve ${batch.transactions.length} transaction${batch.transactions.length === 1 ? '' : 's'} in the project ${batch.project}: ${batch.says.join(' | ')}${details.length ? `. ${details.join('. ')}` : ''}`,