@xgamesfun/solana 0.26.3 → 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.3 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,22 +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.3 examples shop with loot boxes
78
+ npx -y @xgamesfun/solana@0.26.4 examples shop with loot boxes
79
79
 
80
80
  # using a coin you already launched? read it from the chain into its "tokens" entry
81
- npx -y @xgamesfun/solana@0.26.3 token <its mint address>
81
+ npx -y @xgamesfun/solana@0.26.4 token <its mint address>
82
82
 
83
83
  # does it work? where does the money go? what does it cost?
84
- npx -y @xgamesfun/solana@0.26.3 check xsolana.json
85
- npx -y @xgamesfun/solana@0.26.3 simulate xsolana.json
86
- npx -y @xgamesfun/solana@0.26.3 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
87
87
 
88
88
  # the whole economy in plain words, and what a cheater could get
89
- npx -y @xgamesfun/solana@0.26.3 explain xsolana.json
90
- npx -y @xgamesfun/solana@0.26.3 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
91
91
 
92
92
  # go live, approved in your own wallet
93
- npx -y @xgamesfun/solana@0.26.3 approve xsolana.json
93
+ npx -y @xgamesfun/solana@0.26.4 approve xsolana.json
94
94
  ```
95
95
 
96
96
  Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
@@ -5,11 +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';
9
11
  import { mintState, powersOf } from '../chain/token2022.js';
10
12
  import { accountKind, decode } from '../client/accounts.js';
11
13
  import { BUBBLEGUM_PROGRAM, LOOT_PROGRAM, pda } from '../client/pda.js';
12
- 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';
13
16
  import { madeSoFar } from '../economy/plan.mjs';
14
17
 
15
18
  /** A `live` section that does not match the chain: the message says what differs, in plain words. */
@@ -116,6 +119,108 @@ export async function checkLive({ live, m, wallet, chain }) {
116
119
  return { owner, project };
117
120
  }
118
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
+
119
224
  /** A token the spec names by its mint address that is not that mint on chain: the message says what differs. */
120
225
  export class MintMismatch extends Error {}
121
226
 
@@ -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
 
@@ -64,9 +64,19 @@ export function approvalPage(token) {
64
64
  let legalShown = new Set();
65
65
  function render(state) {
66
66
  last = state;
67
- $('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}”`;
68
76
  $('sub').textContent = state.done
69
- ? `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.`
70
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}` : ''}` : ''}`;
71
81
  $('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
72
82
  const steps = $('steps');
@@ -91,8 +101,10 @@ export function approvalPage(token) {
91
101
  return li;
92
102
  }),
93
103
  );
94
- const warnings = state.warnings ?? [];
95
- $('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';
96
108
  $('warnings').replaceChildren(
97
109
  ...warnings.map(w => Object.assign(document.createElement('li'), { textContent: w })),
98
110
  );
@@ -114,7 +126,9 @@ export function approvalPage(token) {
114
126
  // A live section the chain does not bear out: nothing can be approved.
115
127
  $('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread || reading;
116
128
  $('status').textContent = state.done
117
- ? 'Done.'
129
+ ? nothingSent
130
+ ? 'Nothing to approve.'
131
+ : 'Done.'
118
132
  : state.refused
119
133
  ? 'Nothing can be approved: see above.'
120
134
  : !account
@@ -27,7 +27,7 @@ import { ACCOUNT_OVERHEAD, compressedTree, model, MOST_RENT_PER_BYTE } from '../
27
27
  import { madeSoFar, plan, PER_APPROVAL } from '../economy/plan.mjs';
28
28
  import { compareDetails, liveStepDetails, namesOf, outsideAddresses, stepDetails } from './details.mjs';
29
29
  import { freshKeypair, verifySignature } from './ed25519.mjs';
30
- import { checkLive, checkMints, LiveMismatch, MintMismatch } from './live-check.mjs';
30
+ import { checkLive, checkMints, LiveMismatch, MintMismatch, readRenames } from './live-check.mjs';
31
31
 
32
32
  const PREPARE_TTL_MS = 90_000;
33
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -150,8 +150,41 @@ export async function createApproval({
150
150
  const signatures = [];
151
151
  const at = now();
152
152
  const mode = !spec.live ? 'new' : spec.live.partial ? 'resume' : 'change';
153
- /** The parts this session publishes: all of them for a new economy, what changed for a live one. */
154
- let only = mode === 'change' ? changedParts(spec).only : spec.live?.changing ? new Set(spec.live.changing) : null;
153
+ /**
154
+ * The parts this session publishes: all of them for a new economy, what changed for a live one. A change that was
155
+ * cut short (`partial`, with what was `published` before it) goes on as what the file says changed now, with what
156
+ * it was changing when it stopped: a part added to the spec since is published too, never recorded as live unsent,
157
+ * and a change that had no part to publish (a token's name alone) is still a change.
158
+ */
159
+ let only =
160
+ mode === 'change'
161
+ ? changedParts(spec).only
162
+ : spec.live?.published
163
+ ? new Set([...(spec.live.changing ?? []), ...changedParts(spec).only])
164
+ : spec.live?.changing
165
+ ? new Set(spec.live.changing)
166
+ : null;
167
+ if (mode === 'resume' && spec.live.published) spec = { ...spec, live: { ...spec.live, changing: [...only] } };
168
+ /**
169
+ * The live tokens whose name record this session writes again (plan.mjs `renames`), and in words the names the spec
170
+ * changes that can't be written (`held`: their keys). Until a wallet connects: what the file itself says changed
171
+ * since it was published. Once one has: what the chain says (`readRenames`), which is all that is ever sent.
172
+ */
173
+ let renames = new Set(spec.live?.published ? changedParts(spec).renames : []);
174
+ let renameNotes = [];
175
+ let held = new Set();
176
+ /** The tokens whose record already says the spec's new name, though the file's published spec says the old one. */
177
+ let settled = new Set();
178
+ /**
179
+ * The names this session has written itself, with this spec's words: never offered again, even to a connection
180
+ * that reads a slot behind. (A name an earlier session sent is not among them: the spec may have changed since, so
181
+ * the chain alone says whether it is the one wanted.)
182
+ */
183
+ const renamed = new Set();
184
+ /** The transactions this session sent itself. */
185
+ const sentHere = new Set();
186
+ /** What a plan of this session is scoped by. */
187
+ const scope = () => ({ only, renames });
155
188
 
156
189
  /** What is live so far: the spec's own live section (a resumed or changed economy) and what landed this session. */
157
190
  const live = () => spec.live ?? null;
@@ -192,7 +225,21 @@ export async function createApproval({
192
225
  );
193
226
  }
194
227
  try {
195
- return await checkLive({ live: live(), m: model(spec, { now: at }), wallet, chain });
228
+ const m = model(spec, { now: at });
229
+ const trusted = await checkLive({ live: live(), m, wallet, chain });
230
+ // which names are written again is the chain's to say, read for the project's owner
231
+ const read = await readRenames({
232
+ live: live(),
233
+ m,
234
+ chain,
235
+ owner: trusted.owner,
236
+ published: spec.live?.published ?? null,
237
+ });
238
+ renames = new Set(read.renames.filter(key => !renamed.has(key)));
239
+ renameNotes = read.notes;
240
+ held = new Set(read.held);
241
+ settled = new Set(read.settled);
242
+ return trusted;
196
243
  } catch (e) {
197
244
  if (!(e instanceof LiveMismatch)) throw e;
198
245
  throw new ApproveError(
@@ -203,7 +250,7 @@ export async function createApproval({
203
250
  }
204
251
 
205
252
  async function build(owner) {
206
- const p0 = plan(spec, { now: at, live: live(), only });
253
+ const p0 = plan(spec, { now: at, live: live(), ...scope() });
207
254
  if (!p0.ok) throw new ApproveError(400, describe({ refusals: p0.refusals, warnings: [] }));
208
255
  for (const name of p0.freshKeys) if (!keys.has(name)) keys.set(name, freshKeypair());
209
256
  // The accounts the owner makes outside loot: a mint, and the project's tree of compressed NFTs.
@@ -229,7 +276,7 @@ export async function createApproval({
229
276
  live: live() ?? {},
230
277
  now: at,
231
278
  rent,
232
- only,
279
+ ...scope(),
233
280
  slot: hashes[0] ?? null,
234
281
  });
235
282
  if (!p.ok) throw new ApproveError(400, describe({ refusals: p.refusals, warnings: [] }));
@@ -305,8 +352,11 @@ export async function createApproval({
305
352
 
306
353
  /** What each step does, in words, kept after it lands (the plan no longer lists it then). */
307
354
  const said = new Map();
355
+ /** The same for the steps still to do: a step's words become `said` when this session's own transaction lands it. */
356
+ const planned = new Map();
308
357
  function view(p, n = names(), extra = {}) {
309
- const done = new Set(live()?.landed ?? []);
358
+ // A step the plan lists is still to do, whatever landed under its id before (a token's name written, then changed
359
+ // again before the change was gone on with): it is never shown as done, nor among what landed.
310
360
  const steps = p.ok
311
361
  ? p.transactions.flatMap(t =>
312
362
  t.steps.map((s, i) => ({
@@ -314,23 +364,27 @@ export async function createApproval({
314
364
  says: s.says,
315
365
  details: detailsOf(t, i, n),
316
366
  signer: t.signer,
317
- done: done.has(s.id),
367
+ done: false,
318
368
  })),
319
369
  )
320
370
  : [];
321
- for (const s of steps) said.set(s.id, s);
371
+ const todo = new Set(steps.map(s => s.id));
372
+ const done = new Set((live()?.landed ?? []).filter(id => !todo.has(id)));
373
+ // (what a landed step did is kept in its own words: a later step of the same id never rewrites them)
374
+ for (const s of steps) planned.set(s.id, s);
322
375
  return {
323
376
  name: spec.name ?? path.basename(file),
324
377
  file: path.basename(file),
325
378
  mode,
326
379
  program: LOOT_PROGRAM,
327
380
  cluster: 'devnet',
328
- setupSol: cost(spec, { now: at, live: live(), only }).setup?.totalSol ?? null,
381
+ setupSol: cost(spec, { now: at, live: live(), ...scope() }).setup?.totalSol ?? null,
329
382
  warnings: [
330
383
  ...(extra.refused ? [extra.refused] : []),
331
384
  ...verdict.warnings.filter(w => !Object.hasOwn(LEGAL, w.code)).map(w => w.message),
332
385
  ...[
333
386
  settings,
387
+ ...renameNotes,
334
388
  unread(),
335
389
  remadeLists(),
336
390
  outsideAddresses(
@@ -344,6 +398,8 @@ export async function createApproval({
344
398
  // doesn't say.
345
399
  legal: [...legal, ...mintPowers.map(t => ({ code: 'W-MINT-POWERS', says: t.says }))],
346
400
  ...(readKey() ? { read: readKey() } : {}),
401
+ // A token's name the spec changes and the chain can't take: said whatever else is on the page, done or not.
402
+ unwritten: [...renameNotes],
347
403
  authority: model(spec).authority,
348
404
  steps,
349
405
  landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
@@ -372,9 +428,14 @@ export async function createApproval({
372
428
  },
373
429
  /** The steps still to go and what is done, built on stand-in addresses until a wallet connects. */
374
430
  state() {
375
- const p = plan(spec, { now: at, live: live(), only });
431
+ const p = plan(spec, { now: at, live: live(), ...scope() });
376
432
  const v = view(p);
377
- return { ...v, remaining: p.ok ? p.transactions.length : 0, done: p.ok && p.transactions.length === 0 };
433
+ // (a file still marked as under way is settled, and recorded as live, only by a connected wallet's read)
434
+ return {
435
+ ...v,
436
+ remaining: p.ok ? p.transactions.length : 0,
437
+ done: p.ok && p.transactions.length === 0 && !spec.live?.partial,
438
+ };
378
439
  },
379
440
  /**
380
441
  * The same, for the connected `wallet`: built exactly as its batches will be (its own project, the treasury on
@@ -401,11 +462,15 @@ export async function createApproval({
401
462
 
402
463
  async function stateFor(wallet) {
403
464
  let trusted;
404
- let p0 = plan(spec, { now: at, live: live(), only });
465
+ let p0 = plan(spec, { now: at, live: live(), ...scope() });
405
466
  try {
406
467
  await settlePending();
407
- p0 = plan(spec, { now: at, live: live(), only });
468
+ // (what a transaction found landed leaves to do: shown as it is under a refusal too)
469
+ p0 = plan(spec, { now: at, live: live(), ...scope() });
408
470
  trusted = await trust(wallet);
471
+ await finish({ owner: trusted.owner });
472
+ await settle();
473
+ p0 = plan(spec, { now: at, live: live(), ...scope() });
409
474
  } catch (e) {
410
475
  if (!(e instanceof ApproveError)) throw e;
411
476
  const v = view(p0, namesOf(model(spec, { now: at }), { now: at, owner: wallet }), { refused: e.message });
@@ -414,6 +479,14 @@ export async function createApproval({
414
479
  if (!p0.ok || !p0.transactions.length)
415
480
  return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
416
481
  const p = await build(trusted.owner);
482
+ if (!p.transactions.length) {
483
+ // Steps are planned and none builds: the lookup table's, which can't be made without a recent slot (the chain's
484
+ // slot hashes did not read). Nothing needs the table, or the build would have refused, so the setup is as live
485
+ // as it can be made and is recorded so.
486
+ await finish(p.ctx, { nothingBuilds: true });
487
+ const left = plan(spec, { now: at, live: live(), ...scope() });
488
+ return { ...view(left, names(), { project: trusted.project }), remaining: 0, done: !spec.live?.partial };
489
+ }
417
490
  await readLive(p);
418
491
  const v = view(p, namesOf(model(spec, { now: at }), { now: at, owner: wallet, treasury: p.ctx.treasury }), {
419
492
  project: p.ctx.project,
@@ -433,6 +506,8 @@ export async function createApproval({
433
506
  return answer(prepared);
434
507
  await settlePending();
435
508
  const trusted = await trust(wallet);
509
+ await finish({ owner: trusted.owner });
510
+ await settle();
436
511
  // What the chain says a named token's mint can do, where the spec doesn't say it, is read with the wallet's own
437
512
  // state: nothing is handed out to sign until the page has shown those very lines and the owner ticked after.
438
513
  if (readKey() && read !== readKey())
@@ -441,7 +516,11 @@ export async function createApproval({
441
516
  'What the chain says of this economy’s tokens was not on the page when the box was ticked: read the lines above, tick the box again, then approve.',
442
517
  );
443
518
  const p = await build(trusted.owner);
444
- if (!p.transactions.length) throw new ApproveError(409, 'Everything has landed already.');
519
+ if (!p.transactions.length) {
520
+ // (planned steps none of which builds: see stateFor)
521
+ await finish(p.ctx, { nothingBuilds: true });
522
+ throw new ApproveError(409, 'Everything has landed already.');
523
+ }
445
524
  await readLive(p);
446
525
  const authority = p.ctx.authority;
447
526
  const first = p.transactions[0];
@@ -510,6 +589,7 @@ export async function createApproval({
510
589
  // sent twice (`settlePending`).
511
590
  const signature = encodeBase58(readTransaction(wire).signatures[0]);
512
591
  const sent = batch.batch[i];
592
+ sentHere.add(signature);
513
593
  await remember({
514
594
  signature,
515
595
  steps: sent.steps.map(s => s.id),
@@ -540,7 +620,7 @@ export async function createApproval({
540
620
  }
541
621
  }
542
622
  signatures.push(signature);
543
- await landed(sent, batch.ctx, signature);
623
+ await landed(sent, batch.ctx, signature, { more: i < wires.length - 1 });
544
624
  }
545
625
  } finally {
546
626
  busy = false;
@@ -600,6 +680,12 @@ export async function createApproval({
600
680
  const pending = spec.live.pending.filter(p => p.signature !== signature);
601
681
  const { pending: _, ...rest } = spec.live;
602
682
  spec = { ...spec, live: pending.length ? { ...rest, pending } : rest };
683
+ // A change of which nothing was sent that could land is no change under way: the file reads as live again, and
684
+ // the next approve works out the change afresh.
685
+ if (!pending.length && rest.partial && rest.published && !rest.landed?.length) {
686
+ const { partial: _p, landed: _l, changing: _c, ...asItWas } = rest;
687
+ spec = { ...spec, live: asItWas };
688
+ }
603
689
  generation++;
604
690
  await write(file, `${JSON.stringify(spec, null, 2)}\n`);
605
691
  }
@@ -627,10 +713,13 @@ export async function createApproval({
627
713
  const status = await chain.signatureStatus(entry.signature);
628
714
  if (landedStatus(status)) {
629
715
  if (!signatures.includes(entry.signature)) signatures.push(entry.signature);
716
+ // (never recorded as the whole setup here: whether a token's name is still to write is read from the chain
717
+ // first, in `trust`, and `finish` records it after)
630
718
  await landed(
631
719
  { steps: entry.steps.map(id => ({ id })) },
632
720
  { ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) },
633
721
  entry.signature,
722
+ { more: true },
634
723
  );
635
724
  continue;
636
725
  }
@@ -654,15 +743,28 @@ export async function createApproval({
654
743
  /**
655
744
  * One transaction landed: the spec's live section learns its steps, and the file is written at once. It names only
656
745
  * what those steps made (the project once `project` landed, a mint once its `token:` step did, …): an account whose
657
- * own transaction has not landed is made again when the setup goes on.
746
+ * own transaction has not landed is made again when the setup goes on. `more`: more of the same batch is still to
747
+ * send, or the chain has not been read yet for what is left (a transaction found landed from an earlier session):
748
+ * the file is not recorded as live on this landing.
658
749
  */
659
- async function landed(transaction, ctx, signature) {
750
+ async function landed(transaction, ctx, signature, { more = false } = {}) {
660
751
  const was = spec.live ?? {};
661
752
  const ids = transaction.steps.map(s => s.id);
662
753
  const made = new Set(ids);
663
754
  const mints = { ...(was.mints ?? {}) };
664
755
  for (const id of ids)
665
756
  if (id.startsWith('token:') && ctx.mints?.[id.slice(6)]) mints[id.slice(6)] = ctx.mints[id.slice(6)];
757
+ // What this session sent itself did what its steps were shown to do (one found landed from an earlier session
758
+ // was built from the file as it was then, which may have been edited since: only the chain says what it did).
759
+ if (sentHere.has(signature))
760
+ for (const id of ids) {
761
+ if (planned.has(id)) said.set(id, planned.get(id));
762
+ // a name written with this spec's words is done
763
+ if (id.startsWith('name:')) {
764
+ renames.delete(id.slice(5));
765
+ renamed.add(id.slice(5));
766
+ }
767
+ }
666
768
  const progress = {
667
769
  ...was,
668
770
  program: LOOT_PROGRAM,
@@ -670,6 +772,8 @@ export async function createApproval({
670
772
  ...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
671
773
  ...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
672
774
  ...(made.has('table') && ctx.slot != null ? { tables: [tableAddress(ctx.owner, ctx.slot)[0]] } : {}),
775
+ // (a transaction found landed from an earlier session names the table it made itself)
776
+ ...(made.has('table') && ctx.slot == null && ctx.tables?.length ? { tables: ctx.tables } : {}),
673
777
  mints,
674
778
  landed: [...new Set([...(was.landed ?? []), ...ids])],
675
779
  partial: true,
@@ -685,22 +789,77 @@ export async function createApproval({
685
789
  if (!progress.collection) delete progress.collection;
686
790
  spec = { ...spec, live: progress };
687
791
  generation++;
688
- const rest = plan(spec, { now: at, live: spec.live, only });
689
- if (rest.ok && rest.transactions.length === 0) {
690
- const { live: _, ...bare } = spec;
691
- // Everything the setup made, whichever session or transaction made it.
692
- const all = {
693
- ...ctx,
694
- ...Object.fromEntries(
695
- ['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map(k => [k, progress[k] ?? ctx[k]]),
696
- ),
697
- mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) },
792
+ if (!more) record(ctx);
793
+ await write(file, `${JSON.stringify(spec, null, 2)}\n`);
794
+ }
795
+
796
+ /**
797
+ * Records the setup or the change as live, once nothing of it is left: no transaction still looked for, and no step
798
+ * in a plan made with what the chain says of the tokens' names (`trust`, read for this session's wallet before this
799
+ * is ever called). `published` becomes the spec as it is, but for a token's name the chain could not take (`held`):
800
+ * that token keeps the name, ticker and link it was published with, so the file never says of a name what the chain
801
+ * does not, and the next approve says it again. Returns whether it did.
802
+ */
803
+ function record(ctx = {}, { nothingBuilds = false } = {}) {
804
+ const progress = spec.live;
805
+ if (!progress?.partial || progress.pending?.length) return false;
806
+ // `nothingBuilds`: the plan built for this wallet has no transaction, whatever a plan on stand-in addresses lists
807
+ const rest = plan(spec, { now: at, live: progress, ...scope() });
808
+ if (!rest.ok || (rest.transactions.length && !nothingBuilds)) return false;
809
+ const { live: _, ...bare } = spec;
810
+ // Everything the setup made, whichever session or transaction made it.
811
+ const all = {
812
+ ...ctx,
813
+ ...Object.fromEntries(
814
+ ['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map(k => [k, progress[k] ?? ctx[k]]),
815
+ ),
816
+ mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) },
817
+ };
818
+ const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
819
+ const before = progress.published?.tokens ?? null;
820
+ if (before && held.size)
821
+ final.live.published.tokens = final.live.published.tokens.map(t => {
822
+ const was = held.has(t.key) ? before.find(x => x.key === t.key) : null;
823
+ if (!was) return t;
824
+ const { name: _n, symbol: _s, uri: _u, ...kept } = t;
825
+ return {
826
+ ...kept,
827
+ ...Object.fromEntries(['name', 'symbol', 'uri'].filter(f => was[f] !== undefined).map(f => [f, was[f]])),
828
+ };
829
+ });
830
+ final.live = { ...final.live, transactions: progress.transactions ?? [] };
831
+ spec = final;
832
+ only = changedParts(spec).only;
833
+ return true;
834
+ }
835
+
836
+ /** `record`, with the file written when it did: after a wallet's read found nothing left of a setup under way. */
837
+ async function finish(ctx, options) {
838
+ if (!record(ctx, options)) return;
839
+ generation++;
840
+ await write(file, `${JSON.stringify(spec, null, 2)}\n`);
841
+ }
842
+
843
+ /**
844
+ * A name the chain already carries is the published one from here on (`settled`, from this wallet's read): only
845
+ * that token's name, ticker and link move in the file's published spec, so no tool keeps announcing a change that
846
+ * is done. A setup or change under way records it when it is recorded as live.
847
+ */
848
+ async function settle() {
849
+ const now = spec.live;
850
+ if (!now?.published || now.partial || !settled.size) return;
851
+ const tokens = (now.published.tokens ?? []).map(t => {
852
+ const said = settled.has(t.key) ? (spec.tokens ?? []).find(x => x.key === t.key) : null;
853
+ if (!said) return t;
854
+ const { name: _n, symbol: _s, uri: _u, ...kept } = t;
855
+ return {
856
+ ...kept,
857
+ ...Object.fromEntries(['name', 'symbol', 'uri'].filter(f => said[f] !== undefined).map(f => [f, said[f]])),
698
858
  };
699
- const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
700
- final.live = { ...final.live, transactions: progress.transactions };
701
- spec = final;
702
- only = changedParts(spec).only;
703
- }
859
+ });
860
+ spec = { ...spec, live: { ...now, published: { ...now.published, tokens } } };
861
+ settled = new Set();
862
+ generation++;
704
863
  await write(file, `${JSON.stringify(spec, null, 2)}\n`);
705
864
  }
706
865
  }