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