@xgamesfun/solana 0.27.1 → 0.27.2

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.
Files changed (37) hide show
  1. package/README.md +34 -17
  2. package/bin/xsolana.mjs +72 -7
  3. package/js/approve/details.mjs +1 -1
  4. package/js/approve/keyfile.mjs +242 -0
  5. package/js/approve/live-check.mjs +5 -2
  6. package/js/approve/server.mjs +94 -13
  7. package/js/approve/with-key.mjs +298 -0
  8. package/js/chain/lookup.js +11 -0
  9. package/js/chain/programs.js +49 -1
  10. package/js/chain/rpc.js +53 -6
  11. package/js/economy/check.mjs +2 -2
  12. package/js/economy/cost.mjs +20 -4
  13. package/js/economy/explain.mjs +3 -3
  14. package/js/economy/guide.mjs +2 -2
  15. package/js/economy/live.mjs +1 -1
  16. package/js/economy/plan.mjs +34 -6
  17. package/js/economy/review.mjs +1 -1
  18. package/js/economy/schema.json +1 -1
  19. package/js/economy/txsize.mjs +3 -2
  20. package/js/mcp/server.mjs +3 -3
  21. package/js/mcp/tools.mjs +50 -13
  22. package/js/play/index.js +21 -7
  23. package/package.json +5 -1
  24. package/release.json +1 -1
  25. package/skills/xsolana-economy/SKILL.md +58 -19
  26. package/skills/xsolana-economy/references/after-launch.md +43 -12
  27. package/skills/xsolana-economy/references/apps.md +1 -1
  28. package/skills/xsolana-economy/references/sentence-to-spec.md +3 -3
  29. package/skills/xsolana-economy/references/telling-the-person.md +18 -11
  30. package/skills/xsolana-economy/references/trust-and-cheating.md +4 -2
  31. package/types/approve/keyfile.d.mts +36 -0
  32. package/types/chain/lookup.d.ts +12 -0
  33. package/types/chain/programs.d.ts +22 -0
  34. package/types/chain/rpc.d.ts +15 -1
  35. package/types/economy/plan.d.mts +21 -4
  36. package/types/mcp/server.d.mts +4 -3
  37. package/types/mcp/tools.d.mts +14 -5
package/README.md CHANGED
@@ -8,8 +8,8 @@
8
8
  [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.XGamesFun%2Fsolana--kit-8a2be2)](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 you put it live by approving it in
12
- your own wallet. It runs on **loot**, one Solana program that every project shares, so there is no Anchor or Rust to
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. **Approve it.** A page opens on your own computer, lists every transaction, and you approve them in your wallet.
43
- Nothing here ever signs for you or asks for a key.
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.27.1 xsolana mcp`.
79
+ **Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.27.2 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.27.1 examples shop with loot boxes
90
+ npx -y @xgamesfun/solana@0.27.2 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.27.1 token <its mint address>
93
+ npx -y @xgamesfun/solana@0.27.2 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.27.1 check xsolana.json
85
- npx -y @xgamesfun/solana@0.27.1 simulate xsolana.json
86
- npx -y @xgamesfun/solana@0.27.1 cost xsolana.json
96
+ npx -y @xgamesfun/solana@0.27.2 check xsolana.json
97
+ npx -y @xgamesfun/solana@0.27.2 simulate xsolana.json
98
+ npx -y @xgamesfun/solana@0.27.2 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.27.1 explain xsolana.json
90
- npx -y @xgamesfun/solana@0.27.1 review xsolana.json
91
-
92
- # go live, approved in your own wallet
93
- npx -y @xgamesfun/solana@0.27.1 approve xsolana.json
101
+ npx -y @xgamesfun/solana@0.27.2 explain xsolana.json
102
+ npx -y @xgamesfun/solana@0.27.2 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.27.2 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.27.2 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.27.2 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.** Use a devnet wallet. Mainnet waits for an independent audit.
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 in your wallet. If the randomness oracle
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 approve [file] [--port n] [--no-open] open the local page where the owner approves going live in their wallet
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 approve [file] approve going live in your own wallet (a local page; --port n, --no-open)
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
- approve: ['--port', '--no-open'],
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
- if (!a.startsWith('--') || a === '--') {
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
- /** The port `xsolana approve` listens on: a whole number from 1 to 65535, or undefined for any free one. */
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: positional[0] ?? 'xsolana.json', port, open: !flags.has('--no-open') });
224
+ await approve({ file, port, open: !flags.has('--no-open') });
160
225
  break;
161
226
  }
162
227
  case 'connect': {
@@ -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}): check what it pays in your wallet before approving`,
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 new LiveMismatch(
57
- `The file names the project ${made.project}, which belongs to the wallet ${state.owner}, not to the connected wallet ${wallet}. Your own project number ${m.projectNumber} is at ${own}.`,
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}.`);