@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.
@@ -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)), '..');
@@ -71,6 +71,8 @@ export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
71
71
  signatureStatus: async signature =>
72
72
  (await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
73
73
  blockHeight: () => rpc.blockHeight(),
74
+ /** The chain's current epoch (a mint's transfer fee changes on one). */
75
+ epoch: async () => Number((await rpc.call('getEpochInfo')).epoch),
74
76
  };
75
77
  }
76
78
 
@@ -148,11 +150,60 @@ export async function createApproval({
148
150
  const signatures = [];
149
151
  const at = now();
150
152
  const mode = !spec.live ? 'new' : spec.live.partial ? 'resume' : 'change';
151
- /** The parts this session publishes: all of them for a new economy, what changed for a live one. */
152
- 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 });
153
188
 
154
189
  /** What is live so far: the spec's own live section (a resumed or changed economy) and what landed this session. */
155
190
  const live = () => spec.live ?? null;
191
+ /**
192
+ * What the chain says the keys of the mints this spec names can do, where the spec doesn't say it (`checkMints`,
193
+ * read when a wallet connects): shown with the legal flags, so the owner ticks having read them before approving.
194
+ */
195
+ let mintPowers = [];
196
+ /**
197
+ * A name for those lines as they stand (null: none). The page hands it back with the owner's tick: a batch is given
198
+ * out only to a page that showed these very lines, so nothing read after the tick, or never shown, is skipped.
199
+ */
200
+ const readKey = () =>
201
+ mintPowers.length
202
+ ? createHash('sha256')
203
+ .update(JSON.stringify(mintPowers.map(t => t.says)))
204
+ .digest('hex')
205
+ .slice(0, 24)
206
+ : null;
156
207
 
157
208
  /**
158
209
  * The owner a setup for `wallet` is built for, and its project, once every mint the spec names is held against the
@@ -162,7 +213,10 @@ export async function createApproval({
162
213
  */
163
214
  async function trust(wallet) {
164
215
  try {
165
- await checkMints({ m: model(spec, { now: at }), chain });
216
+ const m = model(spec, { now: at });
217
+ // the project a mint the spec calls the project's must name: the live one, else this wallet's own to be
218
+ const project = live()?.project ?? pda.project(wallet, m.projectNumber);
219
+ mintPowers = (await checkMints({ m, chain, project })).powers;
166
220
  } catch (e) {
167
221
  if (!(e instanceof MintMismatch)) throw e;
168
222
  throw new ApproveError(
@@ -171,7 +225,21 @@ export async function createApproval({
171
225
  );
172
226
  }
173
227
  try {
174
- 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;
175
243
  } catch (e) {
176
244
  if (!(e instanceof LiveMismatch)) throw e;
177
245
  throw new ApproveError(
@@ -182,7 +250,7 @@ export async function createApproval({
182
250
  }
183
251
 
184
252
  async function build(owner) {
185
- const p0 = plan(spec, { now: at, live: live(), only });
253
+ const p0 = plan(spec, { now: at, live: live(), ...scope() });
186
254
  if (!p0.ok) throw new ApproveError(400, describe({ refusals: p0.refusals, warnings: [] }));
187
255
  for (const name of p0.freshKeys) if (!keys.has(name)) keys.set(name, freshKeypair());
188
256
  // The accounts the owner makes outside loot: a mint, and the project's tree of compressed NFTs.
@@ -208,7 +276,7 @@ export async function createApproval({
208
276
  live: live() ?? {},
209
277
  now: at,
210
278
  rent,
211
- only,
279
+ ...scope(),
212
280
  slot: hashes[0] ?? null,
213
281
  });
214
282
  if (!p.ok) throw new ApproveError(400, describe({ refusals: p.refusals, warnings: [] }));
@@ -284,8 +352,11 @@ export async function createApproval({
284
352
 
285
353
  /** What each step does, in words, kept after it lands (the plan no longer lists it then). */
286
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();
287
357
  function view(p, n = names(), extra = {}) {
288
- 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.
289
360
  const steps = p.ok
290
361
  ? p.transactions.flatMap(t =>
291
362
  t.steps.map((s, i) => ({
@@ -293,23 +364,27 @@ export async function createApproval({
293
364
  says: s.says,
294
365
  details: detailsOf(t, i, n),
295
366
  signer: t.signer,
296
- done: done.has(s.id),
367
+ done: false,
297
368
  })),
298
369
  )
299
370
  : [];
300
- 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);
301
375
  return {
302
376
  name: spec.name ?? path.basename(file),
303
377
  file: path.basename(file),
304
378
  mode,
305
379
  program: LOOT_PROGRAM,
306
380
  cluster: 'devnet',
307
- setupSol: cost(spec, { now: at, live: live(), only }).setup?.totalSol ?? null,
381
+ setupSol: cost(spec, { now: at, live: live(), ...scope() }).setup?.totalSol ?? null,
308
382
  warnings: [
309
383
  ...(extra.refused ? [extra.refused] : []),
310
384
  ...verdict.warnings.filter(w => !Object.hasOwn(LEGAL, w.code)).map(w => w.message),
311
385
  ...[
312
386
  settings,
387
+ ...renameNotes,
313
388
  unread(),
314
389
  remadeLists(),
315
390
  outsideAddresses(
@@ -319,8 +394,12 @@ export async function createApproval({
319
394
  ].filter(Boolean),
320
395
  ],
321
396
  // The legal flags in plain words, shown in a block of their own whatever the agent said: Approve waits until the
322
- // owner ticks that they read them.
323
- legal,
397
+ // owner ticks that they read them. With them, what the chain says a named token's mint can do that the spec
398
+ // doesn't say.
399
+ legal: [...legal, ...mintPowers.map(t => ({ code: 'W-MINT-POWERS', says: t.says }))],
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],
324
403
  authority: model(spec).authority,
325
404
  steps,
326
405
  landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
@@ -349,9 +428,14 @@ export async function createApproval({
349
428
  },
350
429
  /** The steps still to go and what is done, built on stand-in addresses until a wallet connects. */
351
430
  state() {
352
- const p = plan(spec, { now: at, live: live(), only });
431
+ const p = plan(spec, { now: at, live: live(), ...scope() });
353
432
  const v = view(p);
354
- 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
+ };
355
439
  },
356
440
  /**
357
441
  * The same, for the connected `wallet`: built exactly as its batches will be (its own project, the treasury on
@@ -361,10 +445,13 @@ export async function createApproval({
361
445
  stateFor(wallet) {
362
446
  return serial(() => stateFor(walletAddress(wallet)));
363
447
  },
364
- /** The next batch for `wallet` to sign: [base64 wire], with what each does. */
365
- prepare(wallet) {
448
+ /**
449
+ * The next batch for `wallet` to sign: [base64 wire], with what each does. `read`: the state's `read`, as the page
450
+ * showed it when the owner ticked the box (what the chain says of the tokens this economy names).
451
+ */
452
+ prepare(wallet, read = null) {
366
453
  if (busy) return Promise.reject(new ApproveError(409, 'A batch is being sent: wait for it.'));
367
- return serial(() => prepareFor(walletAddress(wallet)));
454
+ return serial(() => prepareFor(walletAddress(wallet), read));
368
455
  },
369
456
  /** The wallet's answer: checked against what was prepared, then sent and confirmed one by one. */
370
457
  complete(wallet, id, signed) {
@@ -375,11 +462,15 @@ export async function createApproval({
375
462
 
376
463
  async function stateFor(wallet) {
377
464
  let trusted;
378
- let p0 = plan(spec, { now: at, live: live(), only });
465
+ let p0 = plan(spec, { now: at, live: live(), ...scope() });
379
466
  try {
380
467
  await settlePending();
381
- 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() });
382
470
  trusted = await trust(wallet);
471
+ await finish({ owner: trusted.owner });
472
+ await settle();
473
+ p0 = plan(spec, { now: at, live: live(), ...scope() });
383
474
  } catch (e) {
384
475
  if (!(e instanceof ApproveError)) throw e;
385
476
  const v = view(p0, namesOf(model(spec, { now: at }), { now: at, owner: wallet }), { refused: e.message });
@@ -388,6 +479,14 @@ export async function createApproval({
388
479
  if (!p0.ok || !p0.transactions.length)
389
480
  return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
390
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
+ }
391
490
  await readLive(p);
392
491
  const v = view(p, namesOf(model(spec, { now: at }), { now: at, owner: wallet, treasury: p.ctx.treasury }), {
393
492
  project: p.ctx.project,
@@ -395,7 +494,7 @@ export async function createApproval({
395
494
  return { ...v, remaining: p.transactions.length, done: false };
396
495
  }
397
496
 
398
- async function prepareFor(wallet) {
497
+ async function prepareFor(wallet, read = null) {
399
498
  // A second ask while a batch is out for signing (a double click) gets that same batch, not a new one that would
400
499
  // make the first signature fail.
401
500
  if (
@@ -407,8 +506,21 @@ export async function createApproval({
407
506
  return answer(prepared);
408
507
  await settlePending();
409
508
  const trusted = await trust(wallet);
509
+ await finish({ owner: trusted.owner });
510
+ await settle();
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
512
+ // state: nothing is handed out to sign until the page has shown those very lines and the owner ticked after.
513
+ if (readKey() && read !== readKey())
514
+ throw new ApproveError(
515
+ 409,
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.',
517
+ );
410
518
  const p = await build(trusted.owner);
411
- 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
+ }
412
524
  await readLive(p);
413
525
  const authority = p.ctx.authority;
414
526
  const first = p.transactions[0];
@@ -477,6 +589,7 @@ export async function createApproval({
477
589
  // sent twice (`settlePending`).
478
590
  const signature = encodeBase58(readTransaction(wire).signatures[0]);
479
591
  const sent = batch.batch[i];
592
+ sentHere.add(signature);
480
593
  await remember({
481
594
  signature,
482
595
  steps: sent.steps.map(s => s.id),
@@ -507,7 +620,7 @@ export async function createApproval({
507
620
  }
508
621
  }
509
622
  signatures.push(signature);
510
- await landed(sent, batch.ctx, signature);
623
+ await landed(sent, batch.ctx, signature, { more: i < wires.length - 1 });
511
624
  }
512
625
  } finally {
513
626
  busy = false;
@@ -567,6 +680,12 @@ export async function createApproval({
567
680
  const pending = spec.live.pending.filter(p => p.signature !== signature);
568
681
  const { pending: _, ...rest } = spec.live;
569
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
+ }
570
689
  generation++;
571
690
  await write(file, `${JSON.stringify(spec, null, 2)}\n`);
572
691
  }
@@ -594,10 +713,13 @@ export async function createApproval({
594
713
  const status = await chain.signatureStatus(entry.signature);
595
714
  if (landedStatus(status)) {
596
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)
597
718
  await landed(
598
719
  { steps: entry.steps.map(id => ({ id })) },
599
720
  { ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) },
600
721
  entry.signature,
722
+ { more: true },
601
723
  );
602
724
  continue;
603
725
  }
@@ -621,15 +743,28 @@ export async function createApproval({
621
743
  /**
622
744
  * One transaction landed: the spec's live section learns its steps, and the file is written at once. It names only
623
745
  * what those steps made (the project once `project` landed, a mint once its `token:` step did, …): an account whose
624
- * 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.
625
749
  */
626
- async function landed(transaction, ctx, signature) {
750
+ async function landed(transaction, ctx, signature, { more = false } = {}) {
627
751
  const was = spec.live ?? {};
628
752
  const ids = transaction.steps.map(s => s.id);
629
753
  const made = new Set(ids);
630
754
  const mints = { ...(was.mints ?? {}) };
631
755
  for (const id of ids)
632
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
+ }
633
768
  const progress = {
634
769
  ...was,
635
770
  program: LOOT_PROGRAM,
@@ -637,6 +772,8 @@ export async function createApproval({
637
772
  ...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
638
773
  ...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
639
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 } : {}),
640
777
  mints,
641
778
  landed: [...new Set([...(was.landed ?? []), ...ids])],
642
779
  partial: true,
@@ -652,22 +789,77 @@ export async function createApproval({
652
789
  if (!progress.collection) delete progress.collection;
653
790
  spec = { ...spec, live: progress };
654
791
  generation++;
655
- const rest = plan(spec, { now: at, live: spec.live, only });
656
- if (rest.ok && rest.transactions.length === 0) {
657
- const { live: _, ...bare } = spec;
658
- // Everything the setup made, whichever session or transaction made it.
659
- const all = {
660
- ...ctx,
661
- ...Object.fromEntries(
662
- ['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map(k => [k, progress[k] ?? ctx[k]]),
663
- ),
664
- 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]])),
665
858
  };
666
- const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
667
- final.live = { ...final.live, transactions: progress.transactions };
668
- spec = final;
669
- only = changedParts(spec).only;
670
- }
859
+ });
860
+ spec = { ...spec, live: { ...now, published: { ...now.published, tokens } } };
861
+ settled = new Set();
862
+ generation++;
671
863
  await write(file, `${JSON.stringify(spec, null, 2)}\n`);
672
864
  }
673
865
  }
@@ -885,7 +1077,10 @@ export async function approve({
885
1077
  }
886
1078
  const body = req.method === 'POST' ? await readJson(req) : {};
887
1079
  if (req.method === 'POST' && url.pathname === '/api/prepare')
888
- return send(200, await session.prepare(String(body.wallet ?? '')));
1080
+ return send(
1081
+ 200,
1082
+ await session.prepare(String(body.wallet ?? ''), typeof body.read === 'string' ? body.read : null),
1083
+ );
889
1084
  if (req.method === 'POST' && url.pathname === '/api/complete') {
890
1085
  const state = await session.complete(String(body.wallet ?? ''), body.id, body.transactions);
891
1086
  if (state.done) {
@@ -72,5 +72,97 @@ export function createFungibleMetadataIx({ mint, authority, payer = authority, n
72
72
  };
73
73
  }
74
74
 
75
+ const UPDATE_V2 = 15;
76
+ /**
77
+ * Token Metadata's UpdateMetadataAccountV2 for a fungible token's record: a new name, ticker and link ('' for none),
78
+ * all three written as given. `authority` is the record's update authority (for a token a setup made: the project's
79
+ * owner) and signs; it stays the update authority, and the record stays changeable. The record's words are written
80
+ * whole, with no royalty, creators, collection or uses: as a record a setup made has them. One that was given any of
81
+ * those outside these tools would lose them (or refuse the write), so the approval sends this only to a record that
82
+ * has none (`parseRecord`'s `plain`; approve/live-check.mjs readRenames). The mint is untouched, and it costs only the
83
+ * transaction: the record keeps its size.
84
+ */
85
+ export function updateFungibleMetadataIx({ mint, authority, name, symbol, uri = '' }) {
86
+ for (const [field, value] of Object.entries({ name, symbol, uri })) {
87
+ if (typeof value !== 'string') throw new TypeError(`A token's ${field} is text`);
88
+ if (utf8(value).length > METADATA_LIMITS[field])
89
+ throw new RangeError(`A token's ${field} is at most ${METADATA_LIMITS[field]} bytes`);
90
+ }
91
+ return {
92
+ programId: METAPLEX_METADATA_PROGRAM,
93
+ keys: [meta(metadataAddress(mint), false, true), meta(authority, true, false)],
94
+ data: concat(
95
+ Uint8Array.of(UPDATE_V2),
96
+ Uint8Array.of(1), // new data
97
+ text(name),
98
+ text(symbol),
99
+ text(uri),
100
+ uintLE(0, 2), // no resale royalty
101
+ NONE, // no creators
102
+ NONE, // no collection
103
+ NONE, // no uses
104
+ NONE, // the update authority: as it is
105
+ NONE, // primary sale: as it is
106
+ NONE, // changeable: as it is
107
+ ),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * A Token Metadata record as the chain holds it, or null when the data is not one (its first byte says a metadata
113
+ * record; the strings are padded with NULs to their full length):
114
+ * { updateAuthority, mint, name, symbol, uri, royalty, creators, mutable, collection, uses, plain }.
115
+ * `royalty`: its resale royalty, in hundredths of a percent. `creators`: how many it lists. `mutable`: its update
116
+ * authority can still change it. `collection`, `uses`: whether it names one. `plain`: none of royalty, creators,
117
+ * collection or uses, as a setup's own record is: writing its words again changes nothing else of it.
118
+ */
119
+ export function parseRecord(data) {
120
+ try {
121
+ if (!data || data.length < 1 + 32 + 32 + 12 || data[0] !== 4) return null;
122
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
123
+ let at = 65;
124
+ const words = [];
125
+ for (let i = 0; i < 3; i++) {
126
+ const length = view.getUint32(at, true);
127
+ if (at + 4 + length > data.length) return null;
128
+ words.push(new TextDecoder().decode(data.subarray(at + 4, at + 4 + length)).replaceAll('\0', ''));
129
+ at += 4 + length;
130
+ }
131
+ if (at + 3 > data.length) return null;
132
+ const royalty = view.getUint16(at, true);
133
+ at += 2;
134
+ let creators = 0;
135
+ if (data[at++] === 1) {
136
+ creators = view.getUint32(at, true);
137
+ at += 4 + 34 * creators;
138
+ }
139
+ if (at + 2 > data.length) return null;
140
+ const mutable = data[at + 1] === 1;
141
+ at += 2; // sold once, and whether it can be changed
142
+ // What follows is each there or not (a byte), with its value when it is: the edition's nonce, the token's standard,
143
+ // then the collection (verified, and its address) and the uses (a method and two counts). A record cut short
144
+ // before them names neither.
145
+ for (let i = 0; i < 2 && at < data.length; i++) at += data[at] === 1 ? 2 : 1;
146
+ const collection = at < data.length && data[at] === 1;
147
+ at += collection ? 34 : 1;
148
+ const uses = at < data.length && data[at] === 1;
149
+ return {
150
+ updateAuthority: encodeBase58(data.subarray(1, 33)),
151
+ mint: encodeBase58(data.subarray(33, 65)),
152
+ name: words[0],
153
+ symbol: words[1],
154
+ uri: words[2],
155
+ royalty,
156
+ creators,
157
+ mutable,
158
+ collection,
159
+ uses,
160
+ plain: royalty === 0 && creators === 0 && !collection && !uses,
161
+ };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
75
167
  /** The update authority a Token Metadata record names (who can change its name, ticker and link). */
76
168
  export const metadataUpdateAuthority = data => (data?.length >= 33 ? encodeBase58(data.subarray(1, 33)) : null);
package/js/chain/mint.js CHANGED
@@ -5,6 +5,7 @@ import { encodeBase58 } from './base58.js';
5
5
  import { fromUtf8, readUintLE } from './bytes.js';
6
6
  import { NATIVE_MINT, TOKEN_2022_PROGRAM, TOKEN_PROGRAM } from './pda.js';
7
7
  import { MINT_BYTES, TOKEN_ACCOUNT_BYTES } from './token.js';
8
+ import { powersOf } from './token2022.js';
8
9
 
9
10
  /** Where Token-2022 writes what an account with extensions is (1 a mint, 2 a token account): right after a token
10
11
  * account's classic bytes. */
@@ -98,3 +99,57 @@ export function parseMint(address, owner, data, metadataData = null) {
98
99
  uri: meta?.uri || null,
99
100
  };
100
101
  }
102
+
103
+ /** A ticker as a spec's key: lower case, the letters a key takes, a letter first; null when nothing is left. */
104
+ function keyOf(symbol) {
105
+ const key = String(symbol ?? '')
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9-]/g, '')
108
+ .replace(/^[^a-z]+/, '')
109
+ .slice(0, 32);
110
+ // "sol" is SOL's own key in every spec: a coin that calls itself SOL is not it
111
+ return key === 'sol' ? 'sol-coin' : key || null;
112
+ }
113
+ /**
114
+ * A text cut to what a spec takes: at most `most` UTF-16 units (what its schema counts), never half a character, with
115
+ * no control characters (a name is a label on one line).
116
+ */
117
+ function clip(text, most) {
118
+ let out = '';
119
+ for (const c of String(text)) {
120
+ const code = c.codePointAt(0);
121
+ // a control character or a line break is a space: a name is a label on one line
122
+ const plain = code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029 ? ' ' : c;
123
+ if (out.length + plain.length > most) break;
124
+ out += plain;
125
+ }
126
+ return out.replace(/ +/g, ' ').trim();
127
+ }
128
+
129
+ /**
130
+ * A token made elsewhere as an economy spec names it: its `tokens` entry (`source: "existing"`), every field read from
131
+ * the chain so nothing is typed in by hand: the mint, its decimals and token program, its name and ticker when it has
132
+ * a name record, and `powers` (token2022.js `powersOf`: what the mint's own keys can do). `account`: the mint's
133
+ * ({ owner, data }); `record`: its Token Metadata record's data, when there is one. `key`: what the spec will call it
134
+ * (default: its ticker in the letters a key takes, else "token"). `project`: the loot project, whose own tokens read
135
+ * `mintAuthority: "project"`; `epoch`: the chain's current epoch, for a transfer fee since changed (`powersOf`). The
136
+ * name and ticker are cut to what a spec takes, on one line. Wrapped SOL is SOL (`{ key: "sol", source: "sol" }`).
137
+ * Null when `account` is no mint.
138
+ */
139
+ export function tokenEntry(address, account, record = null, { key = null, project = null, epoch = null } = {}) {
140
+ const mint = account ? parseMint(address, account.owner, account.data, record) : null;
141
+ if (!mint) return null;
142
+ if (mint.native) return { key: key ?? 'sol', source: 'sol' };
143
+ const name = mint.name ? clip(mint.name, 32) : '',
144
+ symbol = mint.symbol ? clip(mint.symbol, 10) : '';
145
+ return {
146
+ key: key ?? keyOf(symbol) ?? 'token',
147
+ source: 'existing',
148
+ ...(name ? { name } : {}),
149
+ ...(symbol ? { symbol } : {}),
150
+ decimals: mint.decimals,
151
+ program: mint.program,
152
+ mint: mint.mint,
153
+ powers: powersOf(account, { project, epoch }),
154
+ };
155
+ }