@xgamesfun/solana 0.27.1 → 0.28.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 +34 -17
- package/bin/xsolana.mjs +72 -7
- package/js/approve/details.mjs +1 -1
- package/js/approve/keyfile.mjs +242 -0
- package/js/approve/live-check.mjs +5 -2
- package/js/approve/server.mjs +94 -13
- package/js/approve/with-key.mjs +298 -0
- package/js/chain/lookup.js +11 -0
- package/js/chain/programs.js +49 -1
- package/js/chain/rpc.js +53 -6
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +2 -2
- package/js/economy/check.mjs +2 -2
- package/js/economy/cost.mjs +20 -4
- package/js/economy/explain.mjs +3 -3
- package/js/economy/guide.mjs +2 -2
- package/js/economy/live.mjs +1 -1
- package/js/economy/plan.mjs +34 -6
- package/js/economy/review.mjs +1 -1
- package/js/economy/schema.json +1 -1
- package/js/economy/txsize.mjs +3 -2
- package/js/mcp/server.mjs +3 -3
- package/js/mcp/tools.mjs +50 -13
- package/js/play/index.js +21 -7
- package/package.json +5 -1
- package/release.json +6 -6
- package/skills/xsolana-economy/SKILL.md +58 -19
- package/skills/xsolana-economy/references/after-launch.md +43 -12
- package/skills/xsolana-economy/references/apps.md +1 -1
- package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
- package/skills/xsolana-economy/references/telling-the-person.md +18 -11
- package/skills/xsolana-economy/references/trust-and-cheating.md +4 -2
- package/types/approve/keyfile.d.mts +36 -0
- package/types/chain/lookup.d.ts +12 -0
- package/types/chain/programs.d.ts +22 -0
- package/types/chain/rpc.d.ts +15 -1
- package/types/economy/plan.d.mts +21 -4
- package/types/mcp/server.d.mts +4 -3
- package/types/mcp/tools.d.mts +14 -5
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.XGamesFun/solana-kit)
|
|
9
9
|
|
|
10
10
|
You describe your game's or community's economy in a small JSON file: what players buy, open, stake, win or craft.
|
|
11
|
-
The tools check it, show you where the money goes, tell you what it costs, and then
|
|
12
|
-
|
|
11
|
+
The tools check it, show you where the money goes, tell you what it costs, and then it goes live, signed on your own
|
|
12
|
+
computer once you have read what will be sent and said yes. It runs on **loot**, one Solana program that every project shares, so there is no Anchor or Rust to
|
|
13
13
|
write, deploy or audit yourself.
|
|
14
14
|
|
|
15
15
|
Most people never touch the JSON: they ask an AI agent (Claude Code, Codex, Cursor, ChatGPT or Claude) for _"a fishing
|
|
@@ -39,8 +39,20 @@ Every move leaves a public receipt on chain. Random rolls come from MagicBlock's
|
|
|
39
39
|
1. **Describe it.** In a sentence to your agent, or as a spec file (`xsolana.json`).
|
|
40
40
|
2. **Check it.** `check` refuses anything the program would refuse; `simulate` shows where the money goes, day by
|
|
41
41
|
day; `cost` prices going live and each player action; `review` shows what a cheater could get.
|
|
42
|
-
3. **
|
|
43
|
-
|
|
42
|
+
3. **Put it live.** `approve` lists every transaction with what it pays and trusts, ends with a code, and sends
|
|
43
|
+
nothing; run again with that code, it sends exactly what it listed. A key file on your own computer signs
|
|
44
|
+
(`keygen` makes one for the project). Nothing here asks for a seed phrase or for the key of a wallet you already
|
|
45
|
+
have, and no page opens unless you ask for your browser wallet.
|
|
46
|
+
|
|
47
|
+
**Why a key file and not a browser wallet?** A key file made for the project is a new key that has never held
|
|
48
|
+
anything else, so the wallet that holds what you own is never connected or asked to sign; it signs each transaction
|
|
49
|
+
exactly as it was prepared and shown; and nothing opens in your browser. What it costs: the key is a plain file, so
|
|
50
|
+
anything on your machine that can read it can sign as the project. It is the project's owner, for good (these tools
|
|
51
|
+
can't hand a project to another owner), so keep it like one: outside any repository (the tools refuse one inside,
|
|
52
|
+
unless git ignores it there), never pasted anywhere, and backed up by you, because nothing restores a lost one.
|
|
53
|
+
Unless your spec names a `treasury`, what the economy earns for you lands at the key's address: name a wallet you
|
|
54
|
+
hold (`"treasury": "wallet:<address>"`) before going live. To sign in a browser wallet instead, `approve --browser`
|
|
55
|
+
serves the page for it.
|
|
44
56
|
|
|
45
57
|
New here? The [loot box tutorial](docs/tutorial-loot-box.md) walks through all three with real output.
|
|
46
58
|
|
|
@@ -64,7 +76,7 @@ codex plugin add xsolana@xgamesfun
|
|
|
64
76
|
|
|
65
77
|
**claude.ai, ChatGPT or Claude Desktop:** add `https://xgames.fun/mcp` as a custom connector. Nothing to install.
|
|
66
78
|
|
|
67
|
-
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.
|
|
79
|
+
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.0 xsolana mcp`.
|
|
68
80
|
|
|
69
81
|
**Any agent that reads skills** (Copilot, Gemini CLI, Windsurf…): `npx skills add XGamesFun/solana-kit`.
|
|
70
82
|
|
|
@@ -75,22 +87,26 @@ are in [docs/harnesses.md](docs/harnesses.md).
|
|
|
75
87
|
|
|
76
88
|
```bash
|
|
77
89
|
# find a worked example close to your idea
|
|
78
|
-
npx -y @xgamesfun/solana@0.
|
|
90
|
+
npx -y @xgamesfun/solana@0.28.0 examples shop with loot boxes
|
|
79
91
|
|
|
80
92
|
# using a coin you already launched? read it from the chain into its "tokens" entry
|
|
81
|
-
npx -y @xgamesfun/solana@0.
|
|
93
|
+
npx -y @xgamesfun/solana@0.28.0 token <its mint address>
|
|
82
94
|
|
|
83
95
|
# 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.
|
|
96
|
+
npx -y @xgamesfun/solana@0.28.0 check xsolana.json
|
|
97
|
+
npx -y @xgamesfun/solana@0.28.0 simulate xsolana.json
|
|
98
|
+
npx -y @xgamesfun/solana@0.28.0 cost xsolana.json
|
|
87
99
|
|
|
88
100
|
# the whole economy in plain words, and what a cheater could get
|
|
89
|
-
npx -y @xgamesfun/solana@0.
|
|
90
|
-
npx -y @xgamesfun/solana@0.
|
|
91
|
-
|
|
92
|
-
# go live
|
|
93
|
-
npx -y @xgamesfun/solana@0.
|
|
101
|
+
npx -y @xgamesfun/solana@0.28.0 explain xsolana.json
|
|
102
|
+
npx -y @xgamesfun/solana@0.28.0 review xsolana.json
|
|
103
|
+
|
|
104
|
+
# go live: a key file for the project (it prints the address to send devnet SOL to, never the key)…
|
|
105
|
+
npx -y @xgamesfun/solana@0.28.0 keygen ~/.config/xsolana/my-game.json
|
|
106
|
+
# …then everything going live would send, and a code; nothing is sent
|
|
107
|
+
npx -y @xgamesfun/solana@0.28.0 approve xsolana.json --keypair ~/.config/xsolana/my-game.json
|
|
108
|
+
# …and with that code, exactly what it listed is sent
|
|
109
|
+
npx -y @xgamesfun/solana@0.28.0 approve xsolana.json --keypair ~/.config/xsolana/my-game.json --yes <code>
|
|
94
110
|
```
|
|
95
111
|
|
|
96
112
|
Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
|
|
@@ -122,6 +138,7 @@ module covers buying, opening boxes, staking, crafting, tickets, bids and claims
|
|
|
122
138
|
| `@xgamesfun/solana/client` | one builder per instruction, every address, account and receipt decoders |
|
|
123
139
|
| `@xgamesfun/solana/sponsor` | your server pays players' fees (never their price), so players with no SOL can play |
|
|
124
140
|
| `@xgamesfun/solana/results` | results and vouchers your game's server signs |
|
|
141
|
+
| `@xgamesfun/solana/keyfile` | the project's key file as a signer, for a script of the owner's own steps (Node) |
|
|
125
142
|
| `@xgamesfun/solana` | the tools themselves: `check`, `simulate`, `cost`, `review`, `explainEconomy`, `plan` |
|
|
126
143
|
| `@xgamesfun/solana/mcp/http` | the MCP server as a web handler, to host it yourself |
|
|
127
144
|
| `@xgamesfun/solana/chain/*` | small browser-safe Solana building blocks: base58, Borsh, transactions, tokens, Core |
|
|
@@ -132,7 +149,7 @@ Using `@solana/kit`? `loot.toKitInstruction(ix)` turns any builder's instruction
|
|
|
132
149
|
|
|
133
150
|
## Good to know
|
|
134
151
|
|
|
135
|
-
- **Devnet only, not yet audited.**
|
|
152
|
+
- **Devnet only, not yet audited.** Mainnet waits for an independent audit.
|
|
136
153
|
- **Upgrades can mean setting up again.** No program version so far has carried accounts over. Pin the package version.
|
|
137
154
|
- **Players trust the project.** Your keys can mint and move your project's pools. The program protects players from
|
|
138
155
|
each other and from mistakes, not from the project they chose.
|
|
@@ -147,7 +164,7 @@ built yet: read the [known limits](docs/program.md#known-limits) before promisin
|
|
|
147
164
|
## Questions
|
|
148
165
|
|
|
149
166
|
**How do I make a loot box or gacha on Solana without writing a smart contract?** Describe it as a box (its price,
|
|
150
|
-
its outcomes and their odds), run `check` and `simulate`, then `approve` it
|
|
167
|
+
its outcomes and their odds), run `check` and `simulate`, then `approve` it with your project's key file. If the randomness oracle
|
|
151
168
|
never answers, the buyer can get a refund. See the [tutorial](docs/tutorial-loot-box.md) and the
|
|
152
169
|
[examples](examples).
|
|
153
170
|
|
package/bin/xsolana.mjs
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
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
10
|
// xsolana token <mint> [--json] a token made elsewhere, read from the chain: its `tokens` entry, powers and all
|
|
11
|
-
// xsolana
|
|
11
|
+
// xsolana keygen <file> make a key file for a project's owner (outside any repository; the key is never printed)
|
|
12
|
+
// xsolana approve [file] --keypair <keyfile> [--yes <code>] put the economy live, signed by a key file: no browser, no page
|
|
13
|
+
// xsolana approve [file] --browser [--port n] [--no-open] the same in a browser wallet, on a local page: only when the person asks for it
|
|
12
14
|
// xsolana connect [--agent claude|codex] [--scope user|project] [--local] [--remove] [--dry-run] register the MCP server and the skill
|
|
13
15
|
// xsolana tool --list | xsolana tool <name> '<json>' [--args-file f] [--json] any tool by name
|
|
14
16
|
// xsolana version
|
|
@@ -29,7 +31,9 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
|
|
|
29
31
|
xsolana explain [file] the economy in plain words
|
|
30
32
|
xsolana review [file] what a cheater gets on each route, and what to fix before going live
|
|
31
33
|
xsolana publish [file] what going live does, and how to approve it
|
|
32
|
-
xsolana
|
|
34
|
+
xsolana keygen <file> make a key file for the project’s owner, outside any repository (prints its address, never the key)
|
|
35
|
+
xsolana approve [file] --keypair <keyfile> what going live sends, then (--yes <code>) send it, signed by that key file
|
|
36
|
+
xsolana approve [file] --browser the same on a local page, signed in a browser wallet (--port n, --no-open)
|
|
33
37
|
xsolana status [file] a live economy on chain
|
|
34
38
|
xsolana token <mint> a token made elsewhere, as the chain has it: its "tokens" entry, with what its mint can do
|
|
35
39
|
xsolana connect add the xsolana tools to Claude Code or Codex (--agent claude|codex, --scope user|project, --local, --remove, --dry-run)
|
|
@@ -45,7 +49,7 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
|
|
|
45
49
|
class UsageError extends Error {}
|
|
46
50
|
|
|
47
51
|
/** The flags that take a value: `--name value` or `--name=value`. */
|
|
48
|
-
const VALUED = new Set(['--args-file', '--agent', '--scope', '--port', '--host']);
|
|
52
|
+
const VALUED = new Set(['--args-file', '--agent', '--scope', '--port', '--host', '--keypair', '--yes']);
|
|
49
53
|
/** The flags each command takes; any other is refused. */
|
|
50
54
|
const TOOL_FLAGS = ['--json'];
|
|
51
55
|
const FLAGS = {
|
|
@@ -60,7 +64,8 @@ const FLAGS = {
|
|
|
60
64
|
publish: TOOL_FLAGS,
|
|
61
65
|
status: TOOL_FLAGS,
|
|
62
66
|
token: TOOL_FLAGS,
|
|
63
|
-
|
|
67
|
+
keygen: [],
|
|
68
|
+
approve: ['--keypair', '--yes', '--browser', '--port', '--no-open'],
|
|
64
69
|
connect: ['--agent', '--scope', '--local', '--remove', '--dry-run'],
|
|
65
70
|
tool: ['--list', '--args-file', ...TOOL_FLAGS],
|
|
66
71
|
version: [],
|
|
@@ -73,12 +78,16 @@ function parseArgs(rest) {
|
|
|
73
78
|
const positional = [];
|
|
74
79
|
for (let i = 0; i < rest.length; i++) {
|
|
75
80
|
const a = rest[i];
|
|
76
|
-
|
|
81
|
+
// (a bare -- is the shell's own end of flags, not a file's name)
|
|
82
|
+
if (a === '--') continue;
|
|
83
|
+
if (!a.startsWith('--')) {
|
|
77
84
|
positional.push(a);
|
|
78
85
|
continue;
|
|
79
86
|
}
|
|
80
87
|
const eq = a.indexOf('=');
|
|
81
88
|
const name = eq > 0 ? a.slice(0, eq) : a;
|
|
89
|
+
if (VALUED.has(name) && flags.has(name)) throw new UsageError(`${name} is given twice: give it once.`);
|
|
90
|
+
if (eq > 0 && !VALUED.has(name)) throw new UsageError(`${name} takes no value: write it alone, or leave it out.`);
|
|
82
91
|
flags.add(name);
|
|
83
92
|
if (eq > 0) values.set(name, a.slice(eq + 1));
|
|
84
93
|
else if (VALUED.has(name)) {
|
|
@@ -90,7 +99,17 @@ function parseArgs(rest) {
|
|
|
90
99
|
return { flags, values, positional };
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
/**
|
|
102
|
+
/** What `xsolana approve` says when it is not told how the setup is signed. */
|
|
103
|
+
const HOW_TO_SIGN = `xsolana approve: say how going live is signed.
|
|
104
|
+
|
|
105
|
+
--keypair <keyfile> a key file on this machine signs (the usual way: no browser, no page). It shows everything that
|
|
106
|
+
would be sent, with a code, and sends nothing; run again with --yes <code>, it sends exactly
|
|
107
|
+
that. \`xsolana keygen <file>\` makes a new project's key file; one that is live is signed
|
|
108
|
+
only by the key it went live with.
|
|
109
|
+
--browser a browser wallet signs, on a page this command serves on this machine until it is live. Only
|
|
110
|
+
when the person whose economy it is asks for that; they run it themselves.`;
|
|
111
|
+
|
|
112
|
+
/** The port `xsolana approve --browser` listens on: a whole number from 1 to 65535, or undefined for any free one. */
|
|
94
113
|
function portOf(value) {
|
|
95
114
|
if (value === undefined) return undefined;
|
|
96
115
|
const n = Number(value);
|
|
@@ -153,10 +172,56 @@ async function main(argv) {
|
|
|
153
172
|
if (!positional[0]) throw new UsageError('xsolana token <mint>: give the token’s mint address.');
|
|
154
173
|
await tool('xsolana_token', { mint: positional[0] });
|
|
155
174
|
break;
|
|
175
|
+
case 'keygen': {
|
|
176
|
+
if (!positional[0])
|
|
177
|
+
throw new UsageError(
|
|
178
|
+
'xsolana keygen <file>: say where the key file goes, outside any repository (for example ~/.config/xsolana/my-game.json).',
|
|
179
|
+
);
|
|
180
|
+
if (positional.length > 1)
|
|
181
|
+
throw new UsageError(
|
|
182
|
+
'xsolana keygen <file> makes one key file: give one path (in quotes when it has a space).',
|
|
183
|
+
);
|
|
184
|
+
const { makeKeyFile } = await import('../js/approve/keyfile.mjs');
|
|
185
|
+
const { PACKAGE_VERSION } = await import('../js/mcp/tools.mjs');
|
|
186
|
+
const made = await makeKeyFile(positional[0]);
|
|
187
|
+
// The file as it was named when that is the home folder's (`~/…` reads the same in any shell), else whole; in
|
|
188
|
+
// quotes when a shell would split it.
|
|
189
|
+
const named = positional[0].startsWith('~') ? positional[0] : made.file;
|
|
190
|
+
const word = /^[\w./~:\\-]+$/.test(named) ? named : `"${named}"`;
|
|
191
|
+
console.log(
|
|
192
|
+
[
|
|
193
|
+
`A new key is in ${made.file}${process.platform === 'win32' ? '' : ', readable by you alone'}. Its address:`,
|
|
194
|
+
` ${made.address}`,
|
|
195
|
+
'Whatever is set up with it, it owns, for good: anyone who can read that file controls the project, and every later change is signed by it. Keep the file where it is and never paste it into a chat, a spec, a repository or a page. Back it up yourself (a USB stick, a password manager): if it is lost, nothing can change the project or move what its address holds.',
|
|
196
|
+
`It holds nothing yet. Send it devnet SOL (\`xsolana cost\` says what going live takes; https://faucet.solana.com gives some), then: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair ${word}`,
|
|
197
|
+
].join('\n'),
|
|
198
|
+
);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
156
201
|
case 'approve': {
|
|
202
|
+
const file = positional[0] ?? 'xsolana.json';
|
|
203
|
+
const keyFile = values.get('--keypair');
|
|
204
|
+
const page = flags.has('--browser');
|
|
205
|
+
if (keyFile !== undefined && page)
|
|
206
|
+
throw new UsageError('--keypair and --browser are two ways to sign: give one.');
|
|
207
|
+
if (keyFile !== undefined) {
|
|
208
|
+
if (flags.has('--port') || flags.has('--no-open'))
|
|
209
|
+
throw new UsageError('--port and --no-open go with --browser.');
|
|
210
|
+
const { approveWithKey } = await import('../js/approve/with-key.mjs');
|
|
211
|
+
// It shows, and sends only when given the code of what it showed: it never stops to ask.
|
|
212
|
+
const r = await approveWithKey({ file, keyFile, yes: values.get('--yes') ?? null });
|
|
213
|
+
if (!r.ok) process.exitCode = 1;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (flags.has('--yes')) throw new UsageError('--yes goes with --keypair.');
|
|
217
|
+
if (!page) {
|
|
218
|
+
console.error(HOW_TO_SIGN);
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
157
222
|
const port = portOf(values.get('--port'));
|
|
158
223
|
const { approve } = await import('../js/approve/server.mjs');
|
|
159
|
-
await approve({ file
|
|
224
|
+
await approve({ file, port, open: !flags.has('--no-open') });
|
|
160
225
|
break;
|
|
161
226
|
}
|
|
162
227
|
case 'connect': {
|
package/js/approve/details.mjs
CHANGED
|
@@ -120,7 +120,7 @@ function readDetails(reads, names) {
|
|
|
120
120
|
for (const read of reads) {
|
|
121
121
|
if (read?.unreadable) {
|
|
122
122
|
lines.push(
|
|
123
|
-
`This step could not be read back (${read.unreadable}):
|
|
123
|
+
`This step could not be read back (${read.unreadable}): nothing here shows what it pays, so don’t say yes to it until it reads back (in a browser wallet, check what the wallet shows)`,
|
|
124
124
|
);
|
|
125
125
|
continue;
|
|
126
126
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// A key file: one Solana key on this machine, in the Solana CLI's own format (a JSON array of 64 numbers: the 32-byte
|
|
2
|
+
// seed, then the public key). `xsolana keygen` makes one for a project and `xsolana approve --keypair` signs that
|
|
3
|
+
// project's setup with it, so going live needs no browser wallet and no page. The key is the project's owner:
|
|
4
|
+
// whoever can read the file controls the project. So a key file is only ever made new (never written over), readable
|
|
5
|
+
// by its user alone, kept outside any git repository, and never printed: these functions hand back its address and a
|
|
6
|
+
// way to sign, not its bytes.
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign } from 'node:crypto';
|
|
9
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, stat } from 'node:fs/promises';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { encodeBase58 } from '../chain/base58.js';
|
|
13
|
+
import { readTransaction, wireTransaction } from '../chain/tx.js';
|
|
14
|
+
|
|
15
|
+
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
16
|
+
/** The most a key file holds: 64 numbers of up to three digits, with room for however it was spaced. */
|
|
17
|
+
const MOST_BYTES = 4096;
|
|
18
|
+
|
|
19
|
+
/** A refusal about a key file: said in one line, and it never quotes what the file holds. */
|
|
20
|
+
export class KeyFileError extends Error {}
|
|
21
|
+
|
|
22
|
+
/** `file` as a whole path: a leading `~` is the home folder, whether or not a shell expanded it. */
|
|
23
|
+
export function keyFilePath(file) {
|
|
24
|
+
const text = String(file ?? '');
|
|
25
|
+
if (!text) throw new KeyFileError('Say which key file: a path outside any repository.');
|
|
26
|
+
if (text === '~') return homedir();
|
|
27
|
+
return path.resolve(text.startsWith('~/') || text.startsWith('~\\') ? path.join(homedir(), text.slice(2)) : text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The key a 32-byte seed makes: { address, publicKey, sign(message), drop() }. The private key stays in here. */
|
|
31
|
+
function keyOf(seed) {
|
|
32
|
+
let key = createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, Buffer.from(seed)]), format: 'der', type: 'pkcs8' });
|
|
33
|
+
const publicKey = new Uint8Array(Buffer.from(createPublicKey(key).export({ format: 'jwk' }).x, 'base64url'));
|
|
34
|
+
return {
|
|
35
|
+
address: encodeBase58(publicKey),
|
|
36
|
+
publicKey,
|
|
37
|
+
sign(message) {
|
|
38
|
+
if (!key) throw new Error('This key was dropped.');
|
|
39
|
+
return new Uint8Array(sign(null, Buffer.from(message), key));
|
|
40
|
+
},
|
|
41
|
+
/** The key signs nothing more. */
|
|
42
|
+
drop() {
|
|
43
|
+
key = null;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const exists = p =>
|
|
49
|
+
stat(p).then(
|
|
50
|
+
() => true,
|
|
51
|
+
() => false,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** The nearest folder at or above `dir` that holds a `.git` (a repository's work tree, as far as a look can tell). */
|
|
55
|
+
async function gitFolderAbove(dir) {
|
|
56
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
57
|
+
if (await exists(path.join(at, '.git'))) return at;
|
|
58
|
+
if (path.dirname(at) === at) return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The environment git is asked in: the caller's, without its own git settings (GIT_DIR and the like), which would answer for another repository. */
|
|
63
|
+
const gitEnv = () => ({
|
|
64
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))),
|
|
65
|
+
LC_ALL: 'C',
|
|
66
|
+
LANGUAGE: 'C',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** git run in `root`: { code, stderr }, `code` 0 when it succeeded, its exit code otherwise, 'ENOENT' with no git. */
|
|
70
|
+
const git = (root, args, run = execFile) =>
|
|
71
|
+
new Promise(resolve => {
|
|
72
|
+
const done = (error, _stdout, stderr) =>
|
|
73
|
+
resolve({ code: error ? (error.code ?? 1) : 0, stderr: String(stderr ?? '') });
|
|
74
|
+
try {
|
|
75
|
+
run('git', ['-C', root, ...args], { timeout: 10_000, env: gitEnv() }, done);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
done(e);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What git says of `file` in the folder `root`, which holds a `.git`: 'ignored' (never committed), 'kept' (it would
|
|
83
|
+
* be), 'none' (that `.git` is no repository: an empty folder of that name), or 'unknown' (git could not be asked, or
|
|
84
|
+
* the `.git` is a file, a worktree's or a submodule's pointer, that git can't follow: a repository whose own folder
|
|
85
|
+
* is away for now).
|
|
86
|
+
*/
|
|
87
|
+
async function gitSays(root, file, run) {
|
|
88
|
+
const asked = await git(root, ['check-ignore', '-q', '--', file], run);
|
|
89
|
+
if (asked.code === 0) return 'ignored';
|
|
90
|
+
if (asked.code === 1) return 'kept';
|
|
91
|
+
if (asked.code === 'ENOENT') return 'unknown';
|
|
92
|
+
const repository = await git(root, ['rev-parse', '--git-dir'], run);
|
|
93
|
+
if (repository.code === 0 || !/not a git repository/i.test(repository.stderr)) return 'unknown';
|
|
94
|
+
const pointer = await lstat(path.join(root, '.git')).then(
|
|
95
|
+
i => !i.isDirectory(),
|
|
96
|
+
() => false,
|
|
97
|
+
);
|
|
98
|
+
return pointer ? 'unknown' : 'none';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The nearest folder at or above `dir` that exists. */
|
|
102
|
+
async function nearest(dir) {
|
|
103
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
104
|
+
if ((await exists(at)) || path.dirname(at) === at) return at;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Refuses a key file inside a git repository's work tree, unless git ignores it there: a key in a repository is
|
|
110
|
+
* committed sooner or later, and a commit is for good. `from` is a folder that exists, at or above the file's own;
|
|
111
|
+
* `real` is the file's whole path with every link followed (the file itself need not exist yet). When git can't be
|
|
112
|
+
* asked, a `.git` above the file is taken at its word.
|
|
113
|
+
*/
|
|
114
|
+
async function outsideRepositories(shown, from, real, { run } = {}) {
|
|
115
|
+
for (let root = await gitFolderAbove(from); root;) {
|
|
116
|
+
const says = await gitSays(root, real, run);
|
|
117
|
+
if (says === 'ignored') return;
|
|
118
|
+
if (says !== 'none')
|
|
119
|
+
throw new KeyFileError(
|
|
120
|
+
`${shown} is inside the git repository at ${root}: a key there is committed sooner or later, and whoever reads it controls the project. Keep it outside any repository, for example ~/.config/xsolana/<name>.json.`,
|
|
121
|
+
);
|
|
122
|
+
// (a `.git` that is no repository: the folders above it are still asked)
|
|
123
|
+
root = path.dirname(root) === root ? null : await gitFolderAbove(path.dirname(root));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What the bytes of a key file hold: its key, or a refusal that quotes none of it. */
|
|
128
|
+
function parseKey(file, text) {
|
|
129
|
+
const not = () =>
|
|
130
|
+
new KeyFileError(
|
|
131
|
+
`${file} is not a Solana key file (a JSON array of 64 numbers, as \`xsolana keygen\` and \`solana-keygen\` write).`,
|
|
132
|
+
);
|
|
133
|
+
let bytes;
|
|
134
|
+
try {
|
|
135
|
+
bytes = JSON.parse(text);
|
|
136
|
+
} catch {
|
|
137
|
+
throw not();
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(bytes) || bytes.length !== 64 || !bytes.every(b => Number.isInteger(b) && b >= 0 && b <= 255))
|
|
140
|
+
throw not();
|
|
141
|
+
const key = keyOf(Uint8Array.from(bytes.slice(0, 32)));
|
|
142
|
+
if (!key.publicKey.every((b, i) => b === bytes[32 + i])) {
|
|
143
|
+
key.drop();
|
|
144
|
+
throw new KeyFileError(
|
|
145
|
+
`${file} is damaged: its second half is not the public key of its first. Nothing was signed with it.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return key;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Makes a new key and writes it to `file`, which must not exist: the folder is made if need be (for its user alone),
|
|
153
|
+
* the file is readable and writable by its user alone, and a path inside a git repository is refused. Returns
|
|
154
|
+
* { file, address }: the whole path and the key's address, never the key.
|
|
155
|
+
* @param {string} file
|
|
156
|
+
* @param {{ run?: Function }} [options] `run`: how git is run (for tests).
|
|
157
|
+
*/
|
|
158
|
+
export async function makeKeyFile(file, { run } = {}) {
|
|
159
|
+
const target = keyFilePath(file);
|
|
160
|
+
const dir = path.dirname(target);
|
|
161
|
+
// (asked before a folder is made: nothing is left behind in a repository that is refused)
|
|
162
|
+
const base = await nearest(dir);
|
|
163
|
+
const from = await realpath(base);
|
|
164
|
+
await outsideRepositories(target, from, path.join(from, path.relative(base, target)), { run });
|
|
165
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
166
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
167
|
+
const seed = Buffer.from(privateKey.export({ format: 'jwk' }).d, 'base64url');
|
|
168
|
+
const pub = Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url');
|
|
169
|
+
let handle;
|
|
170
|
+
try {
|
|
171
|
+
// (`wx`: made new or not at all, so a key already there is never written over)
|
|
172
|
+
handle = await open(target, 'wx', 0o600);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e.code !== 'EEXIST') throw e;
|
|
175
|
+
const address = await readKeyFile(target, { run }).then(
|
|
176
|
+
k => {
|
|
177
|
+
k.drop();
|
|
178
|
+
return k.address;
|
|
179
|
+
},
|
|
180
|
+
() => null,
|
|
181
|
+
);
|
|
182
|
+
throw new KeyFileError(
|
|
183
|
+
address
|
|
184
|
+
? `${target} is already there: a key file whose address is ${address}. Nothing was written. Use it, or name another file.`
|
|
185
|
+
: `${target} is already there, and nothing is ever written over: name another file.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await handle.writeFile(`${JSON.stringify([...seed, ...pub])}\n`);
|
|
190
|
+
await handle.sync();
|
|
191
|
+
} finally {
|
|
192
|
+
await handle.close();
|
|
193
|
+
}
|
|
194
|
+
await chmod(target, 0o600);
|
|
195
|
+
seed.fill(0);
|
|
196
|
+
return { file: target, address: encodeBase58(new Uint8Array(pub)) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reads the key in `file` to sign with: { file, address, sign(message), signTransaction(wire), drop() }, which is
|
|
201
|
+
* also what `@xgamesfun/solana/play` takes as a wallet. Refused: a file other users of the machine can read, one
|
|
202
|
+
* inside a git repository, and anything that is not a whole key. No refusal quotes the file.
|
|
203
|
+
* @param {string} file
|
|
204
|
+
* @param {{ run?: Function, platform?: string }} [options] `run`: how git is run; `platform`: the system's name (for tests).
|
|
205
|
+
*/
|
|
206
|
+
export async function readKeyFile(file, { run, platform = process.platform } = {}) {
|
|
207
|
+
const target = keyFilePath(file);
|
|
208
|
+
let info;
|
|
209
|
+
try {
|
|
210
|
+
info = await stat(target);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e.code === 'ENOENT')
|
|
213
|
+
throw new KeyFileError(
|
|
214
|
+
`${target} does not exist. A new project’s key file is made with \`xsolana keygen\`; a project that is live is signed only by the key it went live with.`,
|
|
215
|
+
);
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
if (!info.isFile()) throw new KeyFileError(`${target} is not a file.`);
|
|
219
|
+
if (info.size > MOST_BYTES) throw new KeyFileError(`${target} is too big to be a Solana key file.`);
|
|
220
|
+
if (platform !== 'win32' && info.mode & 0o077)
|
|
221
|
+
throw new KeyFileError(
|
|
222
|
+
`${target} can be read by other users of this machine (its mode is ${(info.mode & 0o777).toString(8)}). Run: chmod 600 ${target}`,
|
|
223
|
+
);
|
|
224
|
+
const real = await realpath(target);
|
|
225
|
+
await outsideRepositories(target, path.dirname(real), real, { run });
|
|
226
|
+
const key = parseKey(target, await readFile(target, 'utf8'));
|
|
227
|
+
return {
|
|
228
|
+
file: target,
|
|
229
|
+
address: key.address,
|
|
230
|
+
sign: key.sign,
|
|
231
|
+
/** A wire transaction with this key's signature in its own slot, as a wallet's signTransaction gives it back. */
|
|
232
|
+
async signTransaction(wire) {
|
|
233
|
+
const t = readTransaction(wire);
|
|
234
|
+
const slot = t.signers.indexOf(key.address);
|
|
235
|
+
if (slot < 0) throw new KeyFileError(`This transaction is not one ${key.address} signs.`);
|
|
236
|
+
const signatures = [...t.signatures];
|
|
237
|
+
signatures[slot] = key.sign(t.message);
|
|
238
|
+
return wireTransaction(t.message, signatures);
|
|
239
|
+
},
|
|
240
|
+
drop: key.drop,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -53,8 +53,11 @@ export async function checkLive({ live, m, wallet, chain }) {
|
|
|
53
53
|
`The file names the project ${made.project}, which is not project number ${m.projectNumber} of its owner ${state.owner}.`,
|
|
54
54
|
);
|
|
55
55
|
if (state.owner !== wallet && state.authority !== wallet)
|
|
56
|
-
throw
|
|
57
|
-
|
|
56
|
+
throw Object.assign(
|
|
57
|
+
new LiveMismatch(
|
|
58
|
+
`The file names the project ${made.project}, which belongs to ${state.owner}, not to ${wallet}, the address signing now. (That address’s own project number ${m.projectNumber} would be at ${own}.)`,
|
|
59
|
+
),
|
|
60
|
+
{ notOwner: true },
|
|
58
61
|
);
|
|
59
62
|
if (made.treasury && made.treasury !== state.treasury)
|
|
60
63
|
throw new LiveMismatch(`The file says the treasury is ${made.treasury}; on chain it is ${state.treasury}.`);
|