@xgamesfun/solana 0.26.2 → 0.26.3
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 +111 -2
- package/js/approve/page.js +22 -6
- package/js/approve/server.mjs +44 -8
- package/js/chain/mint.js +55 -0
- package/js/chain/token2022.js +89 -0
- package/js/economy/check.mjs +33 -3
- package/js/economy/explain.mjs +1 -1
- package/js/economy/guide.mjs +2 -2
- package/js/economy/legal.mjs +2 -0
- package/js/mcp/tools.mjs +144 -0
- 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 +3 -1
- package/types/chain/mint.d.ts +29 -0
- package/types/chain/token2022.d.ts +38 -0
- package/types/economy/legal.d.mts +1 -0
- 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.3 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.3 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.3 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.3 check xsolana.json
|
|
85
|
+
npx -y @xgamesfun/solana@0.26.3 simulate xsolana.json
|
|
86
|
+
npx -y @xgamesfun/solana@0.26.3 cost xsolana.json
|
|
84
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.3 explain xsolana.json
|
|
90
|
+
npx -y @xgamesfun/solana@0.26.3 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.3 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
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { encodeBase58 } from '../chain/base58.js';
|
|
7
7
|
import { parseCoreCollection, MPL_CORE_PROGRAM } from '../chain/core.js';
|
|
8
8
|
import { parseMint } from '../chain/mint.js';
|
|
9
|
+
import { mintState, powersOf } from '../chain/token2022.js';
|
|
9
10
|
import { accountKind, decode } from '../client/accounts.js';
|
|
10
11
|
import { BUBBLEGUM_PROGRAM, LOOT_PROGRAM, pda } from '../client/pda.js';
|
|
11
12
|
import { SYSTEM_PROGRAM } from '../chain/pda.js';
|
|
@@ -118,15 +119,111 @@ export async function checkLive({ live, m, wallet, chain }) {
|
|
|
118
119
|
/** A token the spec names by its mint address that is not that mint on chain: the message says what differs. */
|
|
119
120
|
export class MintMismatch extends Error {}
|
|
120
121
|
|
|
122
|
+
/** What each power of a mint's own keys lets its holder do, in the checker's words (check.mjs W-MINT-POWERS). */
|
|
123
|
+
const POWER_SAYS = {
|
|
124
|
+
freeze: 'freeze any account holding it, the economy’s pools and vaults included',
|
|
125
|
+
permanentDelegate: 'move or burn it out of any account',
|
|
126
|
+
transferHook: 'run a program of their own on every transfer, which can refuse one',
|
|
127
|
+
feeChangeable: 'raise its transfer fee, up to all of a transfer',
|
|
128
|
+
closeAuthority: 'close the mint and make it again with other powers',
|
|
129
|
+
pausable: 'pause every transfer of it',
|
|
130
|
+
defaultFrozen: 'have every new account of it start frozen',
|
|
131
|
+
defaultState: 'switch every new account of it to start frozen',
|
|
132
|
+
};
|
|
133
|
+
/** What the mint itself does, whoever controls it: said as a sentence of its own. */
|
|
134
|
+
const MINT_SAYS = {
|
|
135
|
+
nonTransferable: 'It cannot be transferred at all: nothing can be paid, staked or given as a prize in it.',
|
|
136
|
+
confidentialTransfers: 'Its holders can turn plain transfers into their account away (confidential transfers).',
|
|
137
|
+
};
|
|
138
|
+
/** What a spec says of a power when it denies it, for the refusal. */
|
|
139
|
+
const POWER_NAMES = {
|
|
140
|
+
freeze: 'no freeze authority',
|
|
141
|
+
permanentDelegate: 'no permanent delegate',
|
|
142
|
+
transferHook: 'no transfer hook',
|
|
143
|
+
feeChangeable: 'a transfer fee nobody can change',
|
|
144
|
+
closeAuthority: 'no close authority',
|
|
145
|
+
pausable: 'no pause',
|
|
146
|
+
defaultFrozen: 'accounts that don’t start frozen',
|
|
147
|
+
defaultState: 'no default account state',
|
|
148
|
+
nonTransferable: 'a token that can be transferred',
|
|
149
|
+
confidentialTransfers: 'no confidential transfers',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A named mint's powers as the chain has them (`onChain`: token2022.js `powersOf`) against what the spec says of them
|
|
154
|
+
* (`said`: its `powers`, or nothing). The spec denying a power the mint has is a contradiction: the checker's warnings
|
|
155
|
+
* were worked out from the spec, so the person read that a risk was not there (throws MintMismatch). A power the spec
|
|
156
|
+
* is silent on is not refused (a spec written before the tools could read a mint names none): it is returned in words,
|
|
157
|
+
* for the page to show before the owner approves. → the sentence, or null when the spec says all the chain has.
|
|
158
|
+
*/
|
|
159
|
+
function powersAgainst(t, what, onChain, said = {}, { state = {}, project = null } = {}) {
|
|
160
|
+
const fix = `Run \`xsolana token ${t.mint}\` and write its "powers" as the chain has them.`;
|
|
161
|
+
for (const [power, name] of Object.entries(POWER_NAMES))
|
|
162
|
+
if (said[power] === false && onChain[power])
|
|
163
|
+
throw new MintMismatch(
|
|
164
|
+
`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}`,
|
|
165
|
+
);
|
|
166
|
+
if (said.mintAuthority === 'none' && onChain.mintAuthority !== 'none')
|
|
167
|
+
throw new MintMismatch(
|
|
168
|
+
`The spec says nobody can mint more of ${what} ("mintAuthority": "none"); on chain its mint ${t.mint} has a mint authority. ${fix}`,
|
|
169
|
+
);
|
|
170
|
+
const fee = onChain.transferFeePercent ?? 0;
|
|
171
|
+
if (said.transferFeePercent !== undefined && fee > Number(said.transferFeePercent))
|
|
172
|
+
throw new MintMismatch(
|
|
173
|
+
`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}`,
|
|
174
|
+
);
|
|
175
|
+
// A default state only matters with someone who can freeze: with no freeze authority nobody can switch it.
|
|
176
|
+
const can = power => (power === 'defaultState' ? !onChain.defaultFrozen && onChain.freeze : true);
|
|
177
|
+
const unsaid = [
|
|
178
|
+
onChain.mintAuthority === 'someone else' &&
|
|
179
|
+
said.mintAuthority === undefined &&
|
|
180
|
+
'mint more of it whenever they want',
|
|
181
|
+
...Object.entries(POWER_SAYS).map(
|
|
182
|
+
([power, says]) => onChain[power] && said[power] === undefined && can(power) && says,
|
|
183
|
+
),
|
|
184
|
+
fee > 0 && said.transferFeePercent === undefined && `keep ${fee}% of every transfer`,
|
|
185
|
+
].filter(Boolean);
|
|
186
|
+
const lines = [];
|
|
187
|
+
if (unsaid.length)
|
|
188
|
+
lines.push(
|
|
189
|
+
`whoever controls the mint of ${what} (${t.mint}) can ${unsaid.join('; ')}. The checker’s warnings did not count it`,
|
|
190
|
+
);
|
|
191
|
+
for (const [power, says] of Object.entries(MINT_SAYS))
|
|
192
|
+
if (onChain[power] && said[power] === undefined) lines.push(says.replace(/\.$/, ''));
|
|
193
|
+
// The spec says the project mints it: true only once the mint's authority is the project. Until then whatever the
|
|
194
|
+
// economy mints in it fails, and whoever holds the authority mints as they like.
|
|
195
|
+
if (['project', 'game'].includes(said.mintAuthority) && onChain.mintAuthority !== 'project')
|
|
196
|
+
lines.push(
|
|
197
|
+
onChain.mintAuthority === 'none'
|
|
198
|
+
? `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`
|
|
199
|
+
: `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'}`,
|
|
200
|
+
);
|
|
201
|
+
if (state.paused) lines.push(`${what} is paused now: nothing of it moves until its issuer ends the pause`);
|
|
202
|
+
if (state.scaled)
|
|
203
|
+
lines.push(
|
|
204
|
+
`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`,
|
|
205
|
+
);
|
|
206
|
+
return lines.length
|
|
207
|
+
? `Read from the chain, and not in the spec: ${lines.join('. ')}. An economy in it is only as safe as the token.`
|
|
208
|
+
: null;
|
|
209
|
+
}
|
|
210
|
+
|
|
121
211
|
/**
|
|
122
212
|
* Checks every token the spec names by its mint address (an existing token, or a game token on a mint of its own)
|
|
123
213
|
* against `chain` ({ account }): the mint must be there, of the token program the spec says, with the decimals the
|
|
124
214
|
* spec says. Every amount the page shows is written in base units with the spec's decimals, and the program takes them
|
|
125
215
|
* 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.
|
|
216
|
+
* decimals, and is not read. What the mint's own keys can do is read too (`powersAgainst`): a power the spec denies
|
|
217
|
+
* and the mint has is refused, and one the spec is silent on is returned for the page to show. Throws MintMismatch.
|
|
218
|
+
* `project`: the project's address (the wallet's own, or the live one), which a mint the project mints must name as
|
|
219
|
+
* its authority. `chain.epoch`, when the chain has it, tells a transfer fee since lowered from one still in force.
|
|
220
|
+
* → { powers: [{ key, mint, says }] }, one for each named token with something its spec doesn't say.
|
|
127
221
|
*/
|
|
128
|
-
export async function checkMints({ m, chain }) {
|
|
222
|
+
export async function checkMints({ m, chain, project = null }) {
|
|
129
223
|
const named = [...m.tokens.values()].filter(t => t.mint && !t.mint.startsWith('@'));
|
|
224
|
+
const powers = [];
|
|
225
|
+
let epoch = null;
|
|
226
|
+
if (named.some(t => !t.wsol) && typeof chain.epoch === 'function') epoch = await chain.epoch().catch(() => null);
|
|
130
227
|
for (const t of named) {
|
|
131
228
|
const what = `the token ${t.symbol ?? t.key} (“${t.key}”)`;
|
|
132
229
|
if (t.wsol) {
|
|
@@ -151,8 +248,20 @@ export async function checkMints({ m, chain }) {
|
|
|
151
248
|
throw new MintMismatch(
|
|
152
249
|
`The spec says ${what} is a ${t.program} token; its mint ${t.mint} is ${mint.program}. Write "program": "${mint.program}".`,
|
|
153
250
|
);
|
|
251
|
+
let onChain, state;
|
|
252
|
+
try {
|
|
253
|
+
onChain = powersOf(account, { project, epoch }) ?? {};
|
|
254
|
+
state = mintState(account);
|
|
255
|
+
} catch {
|
|
256
|
+
throw new MintMismatch(
|
|
257
|
+
`The mint ${t.mint} of ${what} does not read as a mint: its data is not what its token program writes.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const says = powersAgainst(t, what, onChain, t.powers, { state, project });
|
|
261
|
+
if (says) powers.push({ key: t.key, mint: t.mint, says });
|
|
154
262
|
}
|
|
155
263
|
await checkTreasury({ m, chain });
|
|
264
|
+
return { powers };
|
|
156
265
|
}
|
|
157
266
|
|
|
158
267
|
/** Programs whose own accounts are often pasted where their vault was meant: a multisig's or a DAO's account. */
|
package/js/approve/page.js
CHANGED
|
@@ -60,6 +60,8 @@ 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
|
$('title').textContent = state.done ? `“${state.name}” is live` : `Approve “${state.name}”`;
|
|
@@ -97,21 +99,31 @@ export function approvalPage(token) {
|
|
|
97
99
|
// The notice (no warranty, at the owner's own risk, legal where it runs) and this economy's legal flags, in a
|
|
98
100
|
// block of their own whatever the agent said: Approve waits until the owner ticks that they read them.
|
|
99
101
|
const legal = state.legal ?? [];
|
|
102
|
+
// A line that was not there when the box was ticked (what the chain says of a token, read once a wallet connects)
|
|
103
|
+
// has not been read: the box is cleared, and Approve waits for it again.
|
|
104
|
+
const lines = legal.map(f => f.says);
|
|
105
|
+
if (lines.some(l => !legalShown.has(l))) $('legalok').checked = false;
|
|
106
|
+
legalShown = new Set(lines);
|
|
100
107
|
$('legalbox').hidden = Boolean(state.done);
|
|
101
108
|
$('legal').hidden = !legal.length;
|
|
102
109
|
$('legal').replaceChildren(...legal.map(f => Object.assign(document.createElement('li'), { textContent: f.says })));
|
|
103
110
|
const unread = !$('legalok').checked;
|
|
111
|
+
// The state on the page is the connected wallet's own only once the server has read the chain for it: what it
|
|
112
|
+
// says of the tokens comes with that. Until then (the read is on its way, or failed) nothing can be approved.
|
|
113
|
+
const reading = Boolean(account) && !state.done && !state.refused && state.wallet !== account.address;
|
|
104
114
|
// A live section the chain does not bear out: nothing can be approved.
|
|
105
|
-
$('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread;
|
|
115
|
+
$('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread || reading;
|
|
106
116
|
$('status').textContent = state.done
|
|
107
117
|
? 'Done.'
|
|
108
118
|
: state.refused
|
|
109
119
|
? 'Nothing can be approved: see above.'
|
|
110
120
|
: !account
|
|
111
121
|
? 'Connect a wallet first.'
|
|
112
|
-
:
|
|
113
|
-
? '
|
|
114
|
-
:
|
|
122
|
+
: reading
|
|
123
|
+
? 'Reading the chain for your wallet…'
|
|
124
|
+
: unread
|
|
125
|
+
? 'Read the notice above and tick that you have.'
|
|
126
|
+
: `${state.remaining} transaction${state.remaining === 1 ? '' : 's'} to go.`;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
async function refresh() {
|
|
@@ -159,7 +171,10 @@ export function approvalPage(token) {
|
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
$('legalok').addEventListener('change', () => {
|
|
162
|
-
if (last)
|
|
174
|
+
if (!last) return;
|
|
175
|
+
// The wallet's own state never arrived (its read failed): asked again, so the box is ticked on what it shows.
|
|
176
|
+
if (account && !last.done && last.wallet !== account.address) refresh();
|
|
177
|
+
else render(last);
|
|
163
178
|
});
|
|
164
179
|
|
|
165
180
|
// One wallet is connected at once; with several in the browser, the person picks one from a list.
|
|
@@ -201,7 +216,8 @@ export function approvalPage(token) {
|
|
|
201
216
|
const signer = account;
|
|
202
217
|
const signingWallet = wallet;
|
|
203
218
|
try {
|
|
204
|
-
|
|
219
|
+
// `read`: the lines about its tokens this page showed when the box was ticked (the server gives no batch for others)
|
|
220
|
+
const batch = await api('/api/prepare', { wallet: signer.address, read: last?.read ?? null });
|
|
205
221
|
const details = (batch.details ?? []).flat();
|
|
206
222
|
log(
|
|
207
223
|
`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('. ')}` : ''}`,
|
package/js/approve/server.mjs
CHANGED
|
@@ -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
|
|
|
@@ -153,6 +155,22 @@ export async function createApproval({
|
|
|
153
155
|
|
|
154
156
|
/** What is live so far: the spec's own live section (a resumed or changed economy) and what landed this session. */
|
|
155
157
|
const live = () => spec.live ?? null;
|
|
158
|
+
/**
|
|
159
|
+
* What the chain says the keys of the mints this spec names can do, where the spec doesn't say it (`checkMints`,
|
|
160
|
+
* read when a wallet connects): shown with the legal flags, so the owner ticks having read them before approving.
|
|
161
|
+
*/
|
|
162
|
+
let mintPowers = [];
|
|
163
|
+
/**
|
|
164
|
+
* A name for those lines as they stand (null: none). The page hands it back with the owner's tick: a batch is given
|
|
165
|
+
* out only to a page that showed these very lines, so nothing read after the tick, or never shown, is skipped.
|
|
166
|
+
*/
|
|
167
|
+
const readKey = () =>
|
|
168
|
+
mintPowers.length
|
|
169
|
+
? createHash('sha256')
|
|
170
|
+
.update(JSON.stringify(mintPowers.map(t => t.says)))
|
|
171
|
+
.digest('hex')
|
|
172
|
+
.slice(0, 24)
|
|
173
|
+
: null;
|
|
156
174
|
|
|
157
175
|
/**
|
|
158
176
|
* The owner a setup for `wallet` is built for, and its project, once every mint the spec names is held against the
|
|
@@ -162,7 +180,10 @@ export async function createApproval({
|
|
|
162
180
|
*/
|
|
163
181
|
async function trust(wallet) {
|
|
164
182
|
try {
|
|
165
|
-
|
|
183
|
+
const m = model(spec, { now: at });
|
|
184
|
+
// the project a mint the spec calls the project's must name: the live one, else this wallet's own to be
|
|
185
|
+
const project = live()?.project ?? pda.project(wallet, m.projectNumber);
|
|
186
|
+
mintPowers = (await checkMints({ m, chain, project })).powers;
|
|
166
187
|
} catch (e) {
|
|
167
188
|
if (!(e instanceof MintMismatch)) throw e;
|
|
168
189
|
throw new ApproveError(
|
|
@@ -319,8 +340,10 @@ export async function createApproval({
|
|
|
319
340
|
].filter(Boolean),
|
|
320
341
|
],
|
|
321
342
|
// 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
|
-
|
|
343
|
+
// owner ticks that they read them. With them, what the chain says a named token's mint can do that the spec
|
|
344
|
+
// doesn't say.
|
|
345
|
+
legal: [...legal, ...mintPowers.map(t => ({ code: 'W-MINT-POWERS', says: t.says }))],
|
|
346
|
+
...(readKey() ? { read: readKey() } : {}),
|
|
324
347
|
authority: model(spec).authority,
|
|
325
348
|
steps,
|
|
326
349
|
landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
|
|
@@ -361,10 +384,13 @@ export async function createApproval({
|
|
|
361
384
|
stateFor(wallet) {
|
|
362
385
|
return serial(() => stateFor(walletAddress(wallet)));
|
|
363
386
|
},
|
|
364
|
-
/**
|
|
365
|
-
|
|
387
|
+
/**
|
|
388
|
+
* The next batch for `wallet` to sign: [base64 wire], with what each does. `read`: the state's `read`, as the page
|
|
389
|
+
* showed it when the owner ticked the box (what the chain says of the tokens this economy names).
|
|
390
|
+
*/
|
|
391
|
+
prepare(wallet, read = null) {
|
|
366
392
|
if (busy) return Promise.reject(new ApproveError(409, 'A batch is being sent: wait for it.'));
|
|
367
|
-
return serial(() => prepareFor(walletAddress(wallet)));
|
|
393
|
+
return serial(() => prepareFor(walletAddress(wallet), read));
|
|
368
394
|
},
|
|
369
395
|
/** The wallet's answer: checked against what was prepared, then sent and confirmed one by one. */
|
|
370
396
|
complete(wallet, id, signed) {
|
|
@@ -395,7 +421,7 @@ export async function createApproval({
|
|
|
395
421
|
return { ...v, remaining: p.transactions.length, done: false };
|
|
396
422
|
}
|
|
397
423
|
|
|
398
|
-
async function prepareFor(wallet) {
|
|
424
|
+
async function prepareFor(wallet, read = null) {
|
|
399
425
|
// A second ask while a batch is out for signing (a double click) gets that same batch, not a new one that would
|
|
400
426
|
// make the first signature fail.
|
|
401
427
|
if (
|
|
@@ -407,6 +433,13 @@ export async function createApproval({
|
|
|
407
433
|
return answer(prepared);
|
|
408
434
|
await settlePending();
|
|
409
435
|
const trusted = await trust(wallet);
|
|
436
|
+
// 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
|
+
// state: nothing is handed out to sign until the page has shown those very lines and the owner ticked after.
|
|
438
|
+
if (readKey() && read !== readKey())
|
|
439
|
+
throw new ApproveError(
|
|
440
|
+
409,
|
|
441
|
+
'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
|
+
);
|
|
410
443
|
const p = await build(trusted.owner);
|
|
411
444
|
if (!p.transactions.length) throw new ApproveError(409, 'Everything has landed already.');
|
|
412
445
|
await readLive(p);
|
|
@@ -885,7 +918,10 @@ export async function approve({
|
|
|
885
918
|
}
|
|
886
919
|
const body = req.method === 'POST' ? await readJson(req) : {};
|
|
887
920
|
if (req.method === 'POST' && url.pathname === '/api/prepare')
|
|
888
|
-
return send(
|
|
921
|
+
return send(
|
|
922
|
+
200,
|
|
923
|
+
await session.prepare(String(body.wallet ?? ''), typeof body.read === 'string' ? body.read : null),
|
|
924
|
+
);
|
|
889
925
|
if (req.method === 'POST' && url.pathname === '/api/complete') {
|
|
890
926
|
const state = await session.complete(String(body.wallet ?? ''), body.id, body.transactions);
|
|
891
927
|
if (state.done) {
|
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
|
+
}
|