@xgamesfun/solana 0.28.0 → 0.28.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.
- package/DISCLAIMER.md +3 -1
- package/README.md +18 -16
- package/bin/xsolana.mjs +27 -1
- package/js/approve/details.mjs +3 -3
- package/js/approve/live-check.mjs +2 -2
- package/js/approve/page.html +5 -5
- package/js/approve/page.js +24 -4
- package/js/approve/server.mjs +66 -78
- package/js/approve/wallet-adds.mjs +180 -0
- package/js/approve/with-key.mjs +7 -4
- package/js/chain/borsh.js +5 -3
- package/js/chain/pda.js +6 -0
- package/js/chain/rpc.js +26 -10
- package/js/client/accounts.js +6 -6
- package/js/client/cluster.js +136 -0
- package/js/client/grant.js +2 -2
- package/js/client/ix.js +16 -16
- package/js/client/pda.js +18 -5
- package/js/economy/check.mjs +28 -13
- package/js/economy/explain.mjs +2 -1
- package/js/economy/guide.mjs +6 -4
- package/js/economy/live.mjs +2 -2
- package/js/economy/plan.mjs +3 -3
- package/js/economy/schema.json +2 -2
- package/js/mcp/server.mjs +35 -4
- package/js/mcp/tools.mjs +56 -45
- package/js/mcp/with-cluster.mjs +25 -0
- package/js/play/index.js +42 -37
- package/js/play/sponsor.js +4 -4
- package/js/results/index.js +7 -6
- package/js/results/server.js +4 -3
- package/js/results/wire.js +9 -8
- package/package.json +6 -1
- package/release.json +6 -1
- package/skills/xsolana-economy/SKILL.md +13 -9
- package/skills/xsolana-economy/references/apps.md +2 -1
- package/skills/xsolana-economy/references/telling-the-person.md +1 -1
- package/skills/xsolana-economy/references/trust-and-cheating.md +1 -1
- package/types/chain/borsh.d.ts +4 -2
- package/types/chain/pda.d.ts +6 -0
- package/types/chain/rpc.d.ts +10 -4
- package/types/client/cluster.d.ts +61 -0
- package/types/client/idl/loot.d.ts +9 -9
- package/types/client/pda.d.ts +1 -1
- package/types/economy/check.d.mts +1 -2
- package/types/economy/plan.d.mts +16 -16
- package/types/mcp/server.d.mts +1 -1
- package/types/mcp/tools.d.mts +9 -7
- package/types/mcp/with-cluster.d.mts +8 -0
- package/types/play/index.d.ts +2 -2
- package/types/results/index.d.ts +6 -6
- package/types/results/server.d.ts +3 -3
- package/types/results/wire.d.ts +8 -8
package/DISCLAIMER.md
CHANGED
|
@@ -19,7 +19,9 @@ Read this before you use this package, its tools or the loot program. Using them
|
|
|
19
19
|
wallets, token and NFT programs, npm and any results server. Any of them can fail, change or stop.
|
|
20
20
|
- The public results server (`https://play.xgames.fun/xsolana`) is offered as is, may change or stop at any time, and
|
|
21
21
|
carries no promise of uptime or of what it vouches for.
|
|
22
|
-
- **
|
|
22
|
+
- **Mainnet is real money, and the program has not been independently audited.** loot runs on devnet, where nothing
|
|
23
|
+
has value, and on mainnet, where everything does. An economy is on devnet unless its file says
|
|
24
|
+
`"network": "mainnet"`. What is sent to the program on mainnet can be lost, and you use it there at your own risk.
|
|
23
25
|
|
|
24
26
|
## Use it legally
|
|
25
27
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@xgamesfun/solana)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](#good-to-know)
|
|
7
|
+
[](#good-to-know)
|
|
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.
|
|
@@ -15,8 +15,9 @@ write, deploy or audit yourself.
|
|
|
15
15
|
Most people never touch the JSON: they ask an AI agent (Claude Code, Codex, Cursor, ChatGPT or Claude) for _"a fishing
|
|
16
16
|
game where each cast costs 0.02 SOL, with a rare golden fish and a monthly draw"_ and the agent does the rest.
|
|
17
17
|
|
|
18
|
-
> **
|
|
19
|
-
>
|
|
18
|
+
> **On devnet and on mainnet.** An economy runs on devnet, where nothing is real money, unless its file says
|
|
19
|
+
> `"network": "mainnet"`, where everything is. The program is not independently audited and the tools are provided as
|
|
20
|
+
> is: read [DISCLAIMER.md](DISCLAIMER.md) before you use them, and before real money above all.
|
|
20
21
|
|
|
21
22
|
## What you can build
|
|
22
23
|
|
|
@@ -76,7 +77,7 @@ codex plugin add xsolana@xgamesfun
|
|
|
76
77
|
|
|
77
78
|
**claude.ai, ChatGPT or Claude Desktop:** add `https://xgames.fun/mcp` as a custom connector. Nothing to install.
|
|
78
79
|
|
|
79
|
-
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.
|
|
80
|
+
**Cursor, Cline, opencode or any MCP client:** run `npx -y --package=@xgamesfun/solana@0.28.2 xsolana mcp`.
|
|
80
81
|
|
|
81
82
|
**Any agent that reads skills** (Copilot, Gemini CLI, Windsurf…): `npx skills add XGamesFun/solana-kit`.
|
|
82
83
|
|
|
@@ -87,26 +88,26 @@ are in [docs/harnesses.md](docs/harnesses.md).
|
|
|
87
88
|
|
|
88
89
|
```bash
|
|
89
90
|
# find a worked example close to your idea
|
|
90
|
-
npx -y @xgamesfun/solana@0.28.
|
|
91
|
+
npx -y @xgamesfun/solana@0.28.2 examples shop with loot boxes
|
|
91
92
|
|
|
92
93
|
# using a coin you already launched? read it from the chain into its "tokens" entry
|
|
93
|
-
npx -y @xgamesfun/solana@0.28.
|
|
94
|
+
npx -y @xgamesfun/solana@0.28.2 token <its mint address>
|
|
94
95
|
|
|
95
96
|
# does it work? where does the money go? what does it cost?
|
|
96
|
-
npx -y @xgamesfun/solana@0.28.
|
|
97
|
-
npx -y @xgamesfun/solana@0.28.
|
|
98
|
-
npx -y @xgamesfun/solana@0.28.
|
|
97
|
+
npx -y @xgamesfun/solana@0.28.2 check xsolana.json
|
|
98
|
+
npx -y @xgamesfun/solana@0.28.2 simulate xsolana.json
|
|
99
|
+
npx -y @xgamesfun/solana@0.28.2 cost xsolana.json
|
|
99
100
|
|
|
100
101
|
# the whole economy in plain words, and what a cheater could get
|
|
101
|
-
npx -y @xgamesfun/solana@0.28.
|
|
102
|
-
npx -y @xgamesfun/solana@0.28.
|
|
102
|
+
npx -y @xgamesfun/solana@0.28.2 explain xsolana.json
|
|
103
|
+
npx -y @xgamesfun/solana@0.28.2 review xsolana.json
|
|
103
104
|
|
|
104
|
-
# go live: a key file for the project (it prints the address to send
|
|
105
|
-
npx -y @xgamesfun/solana@0.28.
|
|
105
|
+
# go live: a key file for the project (it prints the address to send SOL to, never the key)…
|
|
106
|
+
npx -y @xgamesfun/solana@0.28.2 keygen ~/.config/xsolana/my-game.json
|
|
106
107
|
# …then everything going live would send, and a code; nothing is sent
|
|
107
|
-
npx -y @xgamesfun/solana@0.28.
|
|
108
|
+
npx -y @xgamesfun/solana@0.28.2 approve xsolana.json --keypair ~/.config/xsolana/my-game.json
|
|
108
109
|
# …and with that code, exactly what it listed is sent
|
|
109
|
-
npx -y @xgamesfun/solana@0.28.
|
|
110
|
+
npx -y @xgamesfun/solana@0.28.2 approve xsolana.json --keypair ~/.config/xsolana/my-game.json --yes <code>
|
|
110
111
|
```
|
|
111
112
|
|
|
112
113
|
Each command names an exact version, so you always run the release you meant to. `xsolana help` lists them all.
|
|
@@ -149,7 +150,8 @@ Using `@solana/kit`? `loot.toKitInstruction(ix)` turns any builder's instruction
|
|
|
149
150
|
|
|
150
151
|
## Good to know
|
|
151
152
|
|
|
152
|
-
- **Devnet
|
|
153
|
+
- **Devnet and mainnet, not independently audited.** An economy is on devnet unless its file says
|
|
154
|
+
`"network": "mainnet"`. Mainnet is real money: try everything on devnet first.
|
|
153
155
|
- **Upgrades can mean setting up again.** No program version so far has carried accounts over. Pin the package version.
|
|
154
156
|
- **Players trust the project.** Your keys can mint and move your project's pools. The program protects players from
|
|
155
157
|
each other and from mistakes, not from the project they chose.
|
package/bin/xsolana.mjs
CHANGED
|
@@ -141,6 +141,29 @@ async function main(argv) {
|
|
|
141
141
|
|
|
142
142
|
const fileInput = () => (positional[0] ? { file: positional[0] } : {});
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Names the cluster this command works on from the economy it is given (a file, or a spec passed whole): its
|
|
146
|
+
* "network", devnet when it names none. A file that is missing or not JSON, and a network loot does not know, are
|
|
147
|
+
* left to the tool, which says so in its own words.
|
|
148
|
+
*/
|
|
149
|
+
async function clusterOf({ file = 'xsolana.json', spec = null } = {}) {
|
|
150
|
+
const { CLUSTERS, networkOf, useCluster } = await import('../js/client/cluster.js');
|
|
151
|
+
let network;
|
|
152
|
+
try {
|
|
153
|
+
network = networkOf(spec ?? JSON.parse(await readFile(file, 'utf8')));
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!Object.hasOwn(CLUSTERS, network)) return;
|
|
158
|
+
try {
|
|
159
|
+
useCluster(network);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new UsageError(
|
|
162
|
+
`${spec ? 'The economy' : file} is on ${network}, and XSOLANA_CLUSTER says ${process.env.XSOLANA_CLUSTER}: unset it, or change the economy’s "network".`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
switch (command) {
|
|
145
168
|
case 'mcp': {
|
|
146
169
|
if (flags.has('--http')) {
|
|
@@ -166,6 +189,7 @@ async function main(argv) {
|
|
|
166
189
|
case 'review':
|
|
167
190
|
case 'publish':
|
|
168
191
|
case 'status':
|
|
192
|
+
await clusterOf(fileInput());
|
|
169
193
|
await tool(`xsolana_${command}`, fileInput());
|
|
170
194
|
break;
|
|
171
195
|
case 'token':
|
|
@@ -193,13 +217,14 @@ async function main(argv) {
|
|
|
193
217
|
`A new key is in ${made.file}${process.platform === 'win32' ? '' : ', readable by you alone'}. Its address:`,
|
|
194
218
|
` ${made.address}`,
|
|
195
219
|
'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
|
|
220
|
+
`It holds nothing yet. Send it SOL (\`xsolana cost\` says what going live takes): devnet SOL for an economy on devnet (https://faucet.solana.com gives some), real SOL for one whose file says "network": "mainnet". Then: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair ${word}`,
|
|
197
221
|
].join('\n'),
|
|
198
222
|
);
|
|
199
223
|
break;
|
|
200
224
|
}
|
|
201
225
|
case 'approve': {
|
|
202
226
|
const file = positional[0] ?? 'xsolana.json';
|
|
227
|
+
await clusterOf({ file });
|
|
203
228
|
const keyFile = values.get('--keypair');
|
|
204
229
|
const page = flags.has('--browser');
|
|
205
230
|
if (keyFile !== undefined && page)
|
|
@@ -252,6 +277,7 @@ async function main(argv) {
|
|
|
252
277
|
{ cause: e },
|
|
253
278
|
);
|
|
254
279
|
}
|
|
280
|
+
if (input && typeof input === 'object' && (input.file !== undefined || input.spec)) await clusterOf(input);
|
|
255
281
|
await tool(positional[0], input);
|
|
256
282
|
break;
|
|
257
283
|
}
|
package/js/approve/details.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { decodeBorsh } from '../chain/borsh.js';
|
|
6
6
|
import { equalBytes, toHex } from '../chain/bytes.js';
|
|
7
7
|
import { accountKind, decode, IDL, instructionOf, keyLeaf, merkleRoot } from '../client/index.js';
|
|
8
|
-
import {
|
|
8
|
+
import { lootProgram, pda } from '../client/pda.js';
|
|
9
9
|
import { dateWords, FAR_SECONDS } from '../economy/check.mjs';
|
|
10
10
|
import { fromUnits } from '../economy/model.mjs';
|
|
11
11
|
import { grantArgs } from '../economy/plan.mjs';
|
|
@@ -38,7 +38,7 @@ const leafNames = list => list.flatMap(a => (a.accounts ? leafNames(a.accounts)
|
|
|
38
38
|
|
|
39
39
|
/** A loot instruction read back: { name, args, accounts: { name: address } }, or null for another program's. */
|
|
40
40
|
function readLootInstruction(ix) {
|
|
41
|
-
if (ix.programId !==
|
|
41
|
+
if (ix.programId !== lootProgram()) return null;
|
|
42
42
|
const def = instructionOf(ix.data);
|
|
43
43
|
if (!def) return null;
|
|
44
44
|
const args = {};
|
|
@@ -104,7 +104,7 @@ export async function liveStepDetails(instructions, names, chain) {
|
|
|
104
104
|
reads.push(null);
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
-
if (account.owner !==
|
|
107
|
+
if (account.owner !== lootProgram() || accountKind(account.data) !== RECORD_ACCOUNT[kind])
|
|
108
108
|
throw new Error(`${read.accounts.target} is not the ${kind.toLowerCase()} it should be.`);
|
|
109
109
|
reads.push({
|
|
110
110
|
...read,
|
|
@@ -10,7 +10,7 @@ import { METADATA_LIMITS, metadataAddress, parseRecord } from '../chain/metadata
|
|
|
10
10
|
import { parseMint } from '../chain/mint.js';
|
|
11
11
|
import { mintState, powersOf } from '../chain/token2022.js';
|
|
12
12
|
import { accountKind, decode } from '../client/accounts.js';
|
|
13
|
-
import { BUBBLEGUM_PROGRAM,
|
|
13
|
+
import { BUBBLEGUM_PROGRAM, lootProgram, pda } from '../client/pda.js';
|
|
14
14
|
import { METAPLEX_METADATA_PROGRAM, SYSTEM_PROGRAM } from '../chain/pda.js';
|
|
15
15
|
import { tokenLabels } from '../economy/model.mjs';
|
|
16
16
|
import { madeSoFar } from '../economy/plan.mjs';
|
|
@@ -43,7 +43,7 @@ export async function checkLive({ live, m, wallet, chain }) {
|
|
|
43
43
|
let project = own;
|
|
44
44
|
if (made.project) {
|
|
45
45
|
const account = await read(made.project);
|
|
46
|
-
if (!account || account.owner !==
|
|
46
|
+
if (!account || account.owner !== lootProgram() || accountKind(account.data) !== 'Project')
|
|
47
47
|
throw new LiveMismatch(
|
|
48
48
|
`The file names the project ${made.project}, and there is no loot project at that address.`,
|
|
49
49
|
);
|
package/js/approve/page.html
CHANGED
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
<body>
|
|
162
162
|
<main>
|
|
163
163
|
<header>
|
|
164
|
-
<p class="muted mono flush">loot · devnet · runs on your computer only</p>
|
|
164
|
+
<p class="muted mono flush">loot · <span id="cluster">devnet</span> · runs on your computer only</p>
|
|
165
165
|
<h1 id="title">Approve going live</h1>
|
|
166
166
|
<p class="muted" id="sub">Loading…</p>
|
|
167
167
|
</header>
|
|
@@ -173,8 +173,8 @@
|
|
|
173
173
|
</div>
|
|
174
174
|
<div class="row" id="wallets" hidden></div>
|
|
175
175
|
<p class="muted note">
|
|
176
|
-
Set the wallet to <b>devnet</b>. Each approval shows you the transactions before you sign;
|
|
177
|
-
wallet without it. Nobody else ever sees your key.
|
|
176
|
+
Set the wallet to <b id="walletcluster">devnet</b>. Each approval shows you the transactions before you sign;
|
|
177
|
+
nothing leaves your wallet without it. Nobody else ever sees your key.
|
|
178
178
|
</p>
|
|
179
179
|
</section>
|
|
180
180
|
|
|
@@ -202,8 +202,8 @@
|
|
|
202
202
|
<ul class="warn gap" id="legal" hidden></ul>
|
|
203
203
|
<p class="muted note">The full text is in DISCLAIMER.md, in the package.</p>
|
|
204
204
|
<label class="row gap"
|
|
205
|
-
><input type="checkbox" id="legalok" /> I have read this and accept it.
|
|
206
|
-
needs every point above answered first.</label
|
|
205
|
+
><input type="checkbox" id="legalok" /> I have read this and accept it.
|
|
206
|
+
<span id="money">Devnet is not real money; real money needs every point above answered first.</span></label
|
|
207
207
|
>
|
|
208
208
|
</section>
|
|
209
209
|
|
package/js/approve/page.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// session's token from its <meta name="xsolana-token"> (the page allows no inline script).
|
|
6
6
|
import { fromBase64, toBase64 } from '/js/chain/bytes.js';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// The cluster of this approval (the economy's own), as the server says it: devnet until the first answer.
|
|
9
|
+
let onCluster = 'devnet';
|
|
9
10
|
const $ = id => document.getElementById(id);
|
|
10
11
|
|
|
11
12
|
function walletRegistry() {
|
|
@@ -62,8 +63,23 @@ export function approvalPage(token) {
|
|
|
62
63
|
let last = null;
|
|
63
64
|
// The legal lines the box was last shown with.
|
|
64
65
|
let legalShown = new Set();
|
|
66
|
+
// How many of the lines about what the wallet added have been said on this page.
|
|
67
|
+
let addedSaid = 0;
|
|
65
68
|
function render(state) {
|
|
69
|
+
onCluster = state.cluster ?? 'devnet';
|
|
70
|
+
$('cluster').textContent = onCluster;
|
|
71
|
+
$('walletcluster').textContent = onCluster;
|
|
72
|
+
$('money').textContent =
|
|
73
|
+
onCluster === 'mainnet'
|
|
74
|
+
? 'This is mainnet: everything sent here is real money, and every point above is yours to have answered first.'
|
|
75
|
+
: 'Devnet is not real money; real money needs every point above answered first.';
|
|
66
76
|
last = state;
|
|
77
|
+
// What the wallet added to transactions that landed (its own checks): they were sent as it signed them. Said
|
|
78
|
+
// whenever a state arrives, so one that landed in a batch that then stopped is said too.
|
|
79
|
+
const addedLines = state.walletAdded ?? [];
|
|
80
|
+
for (const line of addedLines.slice(addedSaid))
|
|
81
|
+
log(`${line} It was sent as your wallet signed it: everything listed here is in it, untouched.`);
|
|
82
|
+
addedSaid = Math.max(addedSaid, addedLines.length);
|
|
67
83
|
// A token's name the spec changes and the chain can't take (`unwritten`): said whether or not anything else was
|
|
68
84
|
// sent. With nothing else to send, the page never calls that a landing.
|
|
69
85
|
const unwritten = state.unwritten ?? [];
|
|
@@ -76,7 +92,7 @@ export function approvalPage(token) {
|
|
|
76
92
|
$('sub').textContent = state.done
|
|
77
93
|
? nothingSent
|
|
78
94
|
? `The only change in ${state.file} is a token’s name, and it can’t be written on chain: nothing was sent, and the file is as it was. See below.`
|
|
79
|
-
: `Every step landed on
|
|
95
|
+
: `Every step landed on ${state.cluster}. ${state.file} now holds the project’s addresses: you can close this page.`
|
|
80
96
|
: `${state.mode === 'change' ? 'A change to a live economy' : state.mode === 'resume' ? 'A setup that was cut short goes on' : 'A new economy'} from ${state.file}, on the shared loot program ${state.program.slice(0, 6)}…${state.project ? `, in the project ${state.project}${state.wallet ? `, made from your wallet ${state.wallet}` : ''}` : ''}`;
|
|
81
97
|
$('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
|
|
82
98
|
const steps = $('steps');
|
|
@@ -197,7 +213,7 @@ export function approvalPage(token) {
|
|
|
197
213
|
const found = list();
|
|
198
214
|
if (!found.length) {
|
|
199
215
|
log(
|
|
200
|
-
|
|
216
|
+
`No Solana wallet in this browser. Install one (Phantom, Solflare, Backpack), set it to ${onCluster}, then reload.`,
|
|
201
217
|
'bad',
|
|
202
218
|
);
|
|
203
219
|
return;
|
|
@@ -237,7 +253,11 @@ export function approvalPage(token) {
|
|
|
237
253
|
`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('. ')}` : ''}`,
|
|
238
254
|
);
|
|
239
255
|
const sign = signingWallet.features['solana:signTransaction'];
|
|
240
|
-
const inputs = batch.transactions.map(t => ({
|
|
256
|
+
const inputs = batch.transactions.map(t => ({
|
|
257
|
+
account: signer,
|
|
258
|
+
transaction: fromBase64(t),
|
|
259
|
+
chain: `solana:${onCluster}`,
|
|
260
|
+
}));
|
|
241
261
|
const outputs = await sign.signTransaction(...inputs);
|
|
242
262
|
const signed = outputs.map(o => toBase64(o.signedTransaction));
|
|
243
263
|
$('status').textContent = 'Sending and confirming…';
|
package/js/approve/server.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// wallet, served only when asked for (`xsolana approve --keypair`, js/approve/with-key.mjs, drives this same approval
|
|
3
3
|
// with a key file and no page). The
|
|
4
4
|
// server builds each transaction (js/economy/plan.mjs), has the fresh keys a setup makes sign their part, and sends
|
|
5
|
-
// only what it prepared: a wallet's answer must carry
|
|
6
|
-
//
|
|
5
|
+
// only what it prepared and showed: a wallet's answer must carry that wallet's valid signature of the exact message
|
|
6
|
+
// prepared for it, or of that message with nothing but a wallet's own additions (js/approve/wallet-adds.mjs: its
|
|
7
|
+
// Lighthouse checks, a compute budget). Each prepared batch answers once and expires after 90 seconds. What lands is written back into the spec
|
|
7
8
|
// file as it lands, so a setup cut short goes on from where it stopped. The server never sees the owner's key.
|
|
8
9
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
10
|
import { chmod, mkdtemp, open as openFile, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
@@ -13,14 +14,14 @@ import path from 'node:path';
|
|
|
13
14
|
import { fileURLToPath } from 'node:url';
|
|
14
15
|
import { spawn } from 'node:child_process';
|
|
15
16
|
import { equalBytes, fromBase64, toBase64 } from '../chain/bytes.js';
|
|
16
|
-
import { alreadyProcessed,
|
|
17
|
+
import { alreadyProcessed, rpcClient } from '../chain/rpc.js';
|
|
17
18
|
import { addressBytes, encodeBase58 } from '../chain/base58.js';
|
|
18
19
|
import { slotHashes, tableAddress } from '../chain/lookup.js';
|
|
19
|
-
import {
|
|
20
|
+
import { SYSVAR_SLOT_HASHES } from '../chain/pda.js';
|
|
20
21
|
import { MINT_BYTES } from '../chain/token.js';
|
|
21
|
-
import { compileMessage,
|
|
22
|
+
import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
|
|
22
23
|
import { accountKind, decode } from '../client/accounts.js';
|
|
23
|
-
import {
|
|
24
|
+
import { checkOnCluster, cluster, CLUSTERS, clusterRpcUrl, lootProgram, pda } from '../client/pda.js';
|
|
24
25
|
import { check, describe } from '../economy/check.mjs';
|
|
25
26
|
import { cost } from '../economy/cost.mjs';
|
|
26
27
|
import { LEGAL, legalFlags } from '../economy/legal.mjs';
|
|
@@ -30,10 +31,10 @@ import { madeSoFar, plan, PER_APPROVAL } from '../economy/plan.mjs';
|
|
|
30
31
|
import { compareDetails, liveStepDetails, namesOf, outsideAddresses, stepDetails } from './details.mjs';
|
|
31
32
|
import { freshKeypair, verifySignature } from './ed25519.mjs';
|
|
32
33
|
import { checkLive, checkMints, LiveMismatch, MintMismatch, readRenames } from './live-check.mjs';
|
|
34
|
+
import { walletAdded } from './wallet-adds.mjs';
|
|
33
35
|
|
|
34
36
|
const PREPARE_TTL_MS = 90_000;
|
|
35
37
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
-
const DEVNET = 'https://api.devnet.solana.com';
|
|
37
38
|
|
|
38
39
|
/** A refusal with the HTTP status the page gets. */
|
|
39
40
|
class ApproveError extends Error {
|
|
@@ -44,37 +45,43 @@ class ApproveError extends Error {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* The chain the approval sends to:
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* The chain the approval sends to: this process's cluster (`client/cluster.js`), through XSOLANA_RPC_URL or, on
|
|
49
|
+
* devnet, its public RPC (a local validator passes for devnet). A URL that names another cluster is refused at once,
|
|
50
|
+
* and before anything is sent the chain's genesis block is checked: another cluster is refused whatever the URL says.
|
|
50
51
|
*/
|
|
51
|
-
export function
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
export function clusterChain(url) {
|
|
53
|
+
// A URL that names another cluster is refused here. With none to use (mainnet has no RPC of its own), the refusal
|
|
54
|
+
// waits for the first request, so what is wrong with the economy's file is said first.
|
|
55
|
+
let made = null;
|
|
56
|
+
const client = () => (made ??= rpcClient(clusterRpcUrl(url)));
|
|
57
|
+
if (url || process.env.XSOLANA_RPC_URL || CLUSTERS[cluster()].rpc) client();
|
|
58
|
+
let genesis = null;
|
|
54
59
|
const checked = () =>
|
|
55
|
-
(
|
|
56
|
-
|
|
60
|
+
(genesis ??= checkOnCluster(client()).catch(e => {
|
|
61
|
+
genesis = null;
|
|
57
62
|
throw e;
|
|
58
63
|
}));
|
|
59
64
|
return {
|
|
60
|
-
url
|
|
65
|
+
get url() {
|
|
66
|
+
return client().url ?? url;
|
|
67
|
+
},
|
|
61
68
|
latestBlockhash: async () => {
|
|
62
69
|
await checked();
|
|
63
|
-
return
|
|
70
|
+
return client().latestBlockhash();
|
|
64
71
|
},
|
|
65
|
-
rent: async bytes => BigInt(await
|
|
72
|
+
rent: async bytes => BigInt(await client().call('getMinimumBalanceForRentExemption', [bytes])),
|
|
66
73
|
send: async wire => {
|
|
67
74
|
await checked();
|
|
68
|
-
return
|
|
75
|
+
return client().send(wire);
|
|
69
76
|
},
|
|
70
|
-
confirm: (signature, options) =>
|
|
71
|
-
account: address =>
|
|
77
|
+
confirm: (signature, options) => client().confirm(signature, options),
|
|
78
|
+
account: address => client().account(address),
|
|
72
79
|
/** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
|
|
73
80
|
signatureStatus: async signature =>
|
|
74
|
-
(await
|
|
75
|
-
blockHeight: () =>
|
|
81
|
+
(await client().call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
|
|
82
|
+
blockHeight: () => client().blockHeight(),
|
|
76
83
|
/** The chain's current epoch (a mint's transfer fee changes on one). */
|
|
77
|
-
epoch: async () => Number((await
|
|
84
|
+
epoch: async () => Number((await client().call('getEpochInfo')).epoch),
|
|
78
85
|
};
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -126,7 +133,7 @@ function walletAddress(wallet) {
|
|
|
126
133
|
*/
|
|
127
134
|
export async function createApproval({
|
|
128
135
|
file,
|
|
129
|
-
chain =
|
|
136
|
+
chain = clusterChain(),
|
|
130
137
|
now = () => Math.floor(Date.now() / 1000),
|
|
131
138
|
clock = Date.now,
|
|
132
139
|
write = writeWhole,
|
|
@@ -150,6 +157,8 @@ export async function createApproval({
|
|
|
150
157
|
// Goes up each time the live section changes: a batch prepared before a change is never sent.
|
|
151
158
|
let generation = 0;
|
|
152
159
|
const signatures = [];
|
|
160
|
+
// What a wallet added to transactions this session sent (its own checks, a compute budget): said on the page.
|
|
161
|
+
const walletAdds = [];
|
|
153
162
|
const at = now();
|
|
154
163
|
const mode = !spec.live ? 'new' : spec.live.partial ? 'resume' : 'change';
|
|
155
164
|
/**
|
|
@@ -385,8 +394,8 @@ export async function createApproval({
|
|
|
385
394
|
name: spec.name ?? path.basename(file),
|
|
386
395
|
file: path.basename(file),
|
|
387
396
|
mode,
|
|
388
|
-
program:
|
|
389
|
-
cluster:
|
|
397
|
+
program: lootProgram(),
|
|
398
|
+
cluster: cluster(),
|
|
390
399
|
setupSol: cost(spec, { now: at, live: live(), ...scope() }).setup?.totalSol ?? null,
|
|
391
400
|
warnings: [
|
|
392
401
|
...(extra.refused ? [extra.refused] : []),
|
|
@@ -413,6 +422,8 @@ export async function createApproval({
|
|
|
413
422
|
steps,
|
|
414
423
|
landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
|
|
415
424
|
signatures: [...signatures],
|
|
425
|
+
// (what a wallet added to transactions it signed, in words: they were sent as it signed them)
|
|
426
|
+
walletAdded: [...walletAdds],
|
|
416
427
|
// The project the setup sends to, once a wallet is connected: always that wallet's own (or the one it is the
|
|
417
428
|
// authority of).
|
|
418
429
|
project: extra.project ?? null,
|
|
@@ -597,7 +608,7 @@ export async function createApproval({
|
|
|
597
608
|
prepared = null;
|
|
598
609
|
if (batch.generation !== generation)
|
|
599
610
|
throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
|
|
600
|
-
const wires = checkSigned(batch.wires, signed, wallet);
|
|
611
|
+
const { wires, added } = checkSigned(batch.wires, signed, wallet);
|
|
601
612
|
busy = true;
|
|
602
613
|
try {
|
|
603
614
|
for (const [i, wire] of wires.entries()) {
|
|
@@ -654,6 +665,8 @@ export async function createApproval({
|
|
|
654
665
|
}
|
|
655
666
|
}
|
|
656
667
|
signatures.push(signature);
|
|
668
|
+
// (what the wallet added to it is said once it has landed, never of one that did not)
|
|
669
|
+
if (added[i]) walletAdds.push(added[i]);
|
|
657
670
|
await landed(sent, batch.ctx, signature, { more: i < wires.length - 1 });
|
|
658
671
|
}
|
|
659
672
|
} finally {
|
|
@@ -730,7 +743,7 @@ export async function createApproval({
|
|
|
730
743
|
async function projectOwner(project) {
|
|
731
744
|
if (!project || !chain.account) return null;
|
|
732
745
|
const account = await chain.account(project).catch(() => null);
|
|
733
|
-
return account && account.owner ===
|
|
746
|
+
return account && account.owner === lootProgram() && accountKind(account.data) === 'Project'
|
|
734
747
|
? decode('Project', account.data).owner
|
|
735
748
|
: null;
|
|
736
749
|
}
|
|
@@ -803,7 +816,7 @@ export async function createApproval({
|
|
|
803
816
|
}
|
|
804
817
|
const progress = {
|
|
805
818
|
...was,
|
|
806
|
-
program:
|
|
819
|
+
program: lootProgram(),
|
|
807
820
|
...(made.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
|
|
808
821
|
...(made.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
|
|
809
822
|
...(made.has('tree') && ctx.tree ? { tree: ctx.tree } : {}),
|
|
@@ -928,53 +941,18 @@ const sameText = (a, b) => {
|
|
|
928
941
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
929
942
|
};
|
|
930
943
|
|
|
931
|
-
/** What a Compute Budget instruction asks for, by its first byte. */
|
|
932
|
-
const BUDGET_ASKS = {
|
|
933
|
-
1: 'a heap size',
|
|
934
|
-
2: 'a compute unit limit',
|
|
935
|
-
3: 'a compute unit price (a priority fee of its own)',
|
|
936
|
-
4: 'a limit on the account data loaded',
|
|
937
|
-
};
|
|
938
|
-
|
|
939
944
|
/**
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
945
|
+
* The wallet's signed copies, as they are sent: { wires, added }. Each is the exact prepared message with the wallet's
|
|
946
|
+
* valid signature, or, from a wallet that adds to what it signs, the wallet's own message with its signature, taken
|
|
947
|
+
* only when everything prepared is in it untouched and what it added is a wallet's own (`walletAdded`,
|
|
948
|
+
* js/approve/wallet-adds.mjs: instructions of the Lighthouse program, a heap size). `added`: for each transaction, what
|
|
949
|
+
* was added to it in words, or null. Anything else is refused, and nothing is sent.
|
|
943
950
|
*/
|
|
944
|
-
export function walletChange(ours, theirs) {
|
|
945
|
-
try {
|
|
946
|
-
const [a, b] = [readMessage(ours), readMessage(theirs)];
|
|
947
|
-
const told = m =>
|
|
948
|
-
m.instructions.map(ix => ({
|
|
949
|
-
program: m.keys[ix.programIndex],
|
|
950
|
-
kind: ix.data[0],
|
|
951
|
-
text: `${m.keys[ix.programIndex]} ${ix.accountIndexes.map(i => m.keys[i] ?? `#${i}`).join(',')} ${toBase64(ix.data)}`,
|
|
952
|
-
}));
|
|
953
|
-
const mine = told(a);
|
|
954
|
-
const added = [];
|
|
955
|
-
let at = 0;
|
|
956
|
-
for (const ix of told(b)) {
|
|
957
|
-
if (at < mine.length && ix.text === mine[at].text) at++;
|
|
958
|
-
else added.push(ix);
|
|
959
|
-
}
|
|
960
|
-
if (at !== mine.length || !added.length)
|
|
961
|
-
return a.blockhash !== b.blockhash ? ' (it put another blockhash on it)' : '';
|
|
962
|
-
const what = added.map(ix =>
|
|
963
|
-
ix.program === COMPUTE_BUDGET_PROGRAM
|
|
964
|
-
? (BUDGET_ASKS[ix.kind] ?? 'a compute budget instruction')
|
|
965
|
-
: `an instruction of the program ${ix.program}`,
|
|
966
|
-
);
|
|
967
|
-
return ` (it added ${[...new Set(what)].join(', ')})`;
|
|
968
|
-
} catch {
|
|
969
|
-
return '';
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/** The wallet's signed copies: each the exact prepared message, each with the wallet's valid signature. */
|
|
974
951
|
function checkSigned(prepared, signed, wallet) {
|
|
975
952
|
if (!Array.isArray(signed) || signed.length !== prepared.length)
|
|
976
953
|
throw new ApproveError(400, 'The wallet’s answer does not match the request.');
|
|
977
|
-
|
|
954
|
+
const added = prepared.map(() => null);
|
|
955
|
+
const wires = prepared.map((wire, i) => {
|
|
978
956
|
let mine;
|
|
979
957
|
let theirs;
|
|
980
958
|
try {
|
|
@@ -983,11 +961,20 @@ function checkSigned(prepared, signed, wallet) {
|
|
|
983
961
|
} catch {
|
|
984
962
|
throw new ApproveError(400, 'The wallet returned something that is not a transaction.');
|
|
985
963
|
}
|
|
986
|
-
if (!equalBytes(mine.message, theirs.message))
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
964
|
+
if (!equalBytes(mine.message, theirs.message)) {
|
|
965
|
+
const taken = walletAdded(mine.message, theirs.message, wallet);
|
|
966
|
+
if (!taken.ok)
|
|
967
|
+
throw new ApproveError(
|
|
968
|
+
400,
|
|
969
|
+
`The wallet changed the transaction before signing it (it ${taken.why}), so nothing was sent. A wallet may add its own checks (instructions of the Lighthouse program) to a transaction it alone signs; everything that was prepared and shown here must be in it untouched, and nothing else may be. (A setup that has not started can be signed by a key file instead: xsolana approve --keypair. One that has started is finished by the key that started it.)`,
|
|
970
|
+
);
|
|
971
|
+
// The wallet's own message, with the wallet's own signature: it is the transaction's one signer.
|
|
972
|
+
const signature = theirs.signatures[0];
|
|
973
|
+
if (theirs.signers[0] !== wallet || !verifySignature(wallet, theirs.message, signature))
|
|
974
|
+
throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
|
|
975
|
+
added[i] = `Transaction ${i + 1} of its batch: your wallet added ${taken.added.join(', ')}.`;
|
|
976
|
+
return wireTransaction(theirs.message, [signature]);
|
|
977
|
+
}
|
|
991
978
|
const slot = mine.signers.indexOf(wallet);
|
|
992
979
|
if (slot < 0) throw new ApproveError(400, 'This transaction was not prepared for that wallet.');
|
|
993
980
|
const signature = theirs.signatures[slot];
|
|
@@ -997,6 +984,7 @@ function checkSigned(prepared, signed, wallet) {
|
|
|
997
984
|
signatures[slot] = signature;
|
|
998
985
|
return wireTransaction(mine.message, signatures);
|
|
999
986
|
});
|
|
987
|
+
return { wires, added };
|
|
1000
988
|
}
|
|
1001
989
|
|
|
1002
990
|
// ---------------------------------------------------------------- the page
|
|
@@ -1093,7 +1081,7 @@ export async function approve({
|
|
|
1093
1081
|
file = 'xsolana.json',
|
|
1094
1082
|
port,
|
|
1095
1083
|
open = true,
|
|
1096
|
-
chain =
|
|
1084
|
+
chain = clusterChain(),
|
|
1097
1085
|
log = (...a) => console.error(...a),
|
|
1098
1086
|
openLink = openBrowser,
|
|
1099
1087
|
} = {}) {
|