@xgamesfun/solana 0.26.3 → 0.27.0
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 +9 -9
- package/js/approve/live-check.mjs +106 -1
- package/js/approve/page.html +1 -1
- package/js/approve/page.js +19 -5
- package/js/approve/server.mjs +192 -33
- package/js/chain/metadata.js +92 -0
- package/js/client/compressed.js +12 -5
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +53 -7
- package/js/client/ix.js +44 -9
- package/js/economy/check.mjs +12 -4
- package/js/economy/cost.mjs +6 -5
- package/js/economy/explain.mjs +3 -2
- package/js/economy/guide.mjs +2 -2
- package/js/economy/live.mjs +27 -7
- package/js/economy/model.mjs +13 -2
- package/js/economy/plan.mjs +84 -27
- package/js/economy/schema.json +7 -1
- package/js/mcp/tools.mjs +31 -11
- package/js/play/index.js +519 -193
- package/package.json +1 -1
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +4 -4
- package/skills/xsolana-economy/references/apps.md +6 -0
- package/skills/xsolana-economy/references/telling-the-person.md +4 -2
- package/skills/xsolana-economy/references/trust-and-cheating.md +4 -3
- package/types/chain/metadata.d.ts +45 -0
- package/types/client/compressed.d.ts +5 -2
- package/types/client/ix.d.ts +38 -7
- package/types/economy/cost.d.mts +11 -3
- package/types/economy/live.d.mts +29 -5
- package/types/economy/model.d.mts +6 -0
- package/types/economy/plan.d.mts +22 -5
- package/types/play/index.d.ts +214 -129
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.
|
|
67
|
+
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.27.0 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.
|
|
78
|
+
npx -y @xgamesfun/solana@0.27.0 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.
|
|
81
|
+
npx -y @xgamesfun/solana@0.27.0 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.
|
|
85
|
-
npx -y @xgamesfun/solana@0.
|
|
86
|
-
npx -y @xgamesfun/solana@0.
|
|
84
|
+
npx -y @xgamesfun/solana@0.27.0 check xsolana.json
|
|
85
|
+
npx -y @xgamesfun/solana@0.27.0 simulate xsolana.json
|
|
86
|
+
npx -y @xgamesfun/solana@0.27.0 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.
|
|
90
|
-
npx -y @xgamesfun/solana@0.
|
|
89
|
+
npx -y @xgamesfun/solana@0.27.0 explain xsolana.json
|
|
90
|
+
npx -y @xgamesfun/solana@0.27.0 review xsolana.json
|
|
91
91
|
|
|
92
92
|
# go live, approved in your own wallet
|
|
93
|
-
npx -y @xgamesfun/solana@0.
|
|
93
|
+
npx -y @xgamesfun/solana@0.27.0 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
|
|
package/js/approve/page.html
CHANGED
package/js/approve/page.js
CHANGED
|
@@ -64,9 +64,19 @@ export function approvalPage(token) {
|
|
|
64
64
|
let legalShown = new Set();
|
|
65
65
|
function render(state) {
|
|
66
66
|
last = state;
|
|
67
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
?
|
|
129
|
+
? nothingSent
|
|
130
|
+
? 'Nothing to approve.'
|
|
131
|
+
: 'Done.'
|
|
118
132
|
: state.refused
|
|
119
133
|
? 'Nothing can be approved: see above.'
|
|
120
134
|
: !account
|
package/js/approve/server.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
154
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
367
|
+
done: false,
|
|
318
368
|
})),
|
|
319
369
|
)
|
|
320
370
|
: [];
|
|
321
|
-
|
|
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(),
|
|
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(),
|
|
431
|
+
const p = plan(spec, { now: at, live: live(), ...scope() });
|
|
376
432
|
const v = view(p);
|
|
377
|
-
|
|
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(),
|
|
465
|
+
let p0 = plan(spec, { now: at, live: live(), ...scope() });
|
|
405
466
|
try {
|
|
406
467
|
await settlePending();
|
|
407
|
-
|
|
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)
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
}
|