@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/js/chain/rpc.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // (devnet: about 100 requests per 10 s per IP, 40 for any one method), so 429s are retried with backoff, and so are a
3
3
  // gateway's passing failures (502, 503, 504).
4
4
  import { fromBase64, toBase64 } from './bytes.js';
5
+ import { transactionId } from './tx.js';
5
6
 
6
7
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
7
8
  const NO_OPTIONS = Object.freeze(/** @type {Record<string, any>} */ ({}));
@@ -21,11 +22,19 @@ const BEHIND = [-32005, -32016];
21
22
 
22
23
  /** A JSON-RPC failure, with the node's own message (and logs for a failed simulation). */
23
24
  export class RpcError extends Error {
24
- constructor(message, { code, logs } = NO_OPTIONS) {
25
+ /**
26
+ * `code`: the node's own error code, when a node answered. `logs`: a failed dry run's program logs. `err`: the
27
+ * node's own name for why a transaction was refused ("BlockhashNotFound", "AlreadyProcessed", or the program's
28
+ * error as the chain writes it). `maybeTaken`: a send whose earlier try got no answer, so a node may have taken the
29
+ * transaction although the last try was refused.
30
+ */
31
+ constructor(message, { code, logs, err, maybeTaken } = NO_OPTIONS) {
25
32
  super(message);
26
33
  this.name = 'RpcError';
27
34
  this.code = code;
28
35
  this.logs = logs;
36
+ this.err = err;
37
+ this.maybeTaken = Boolean(maybeTaken);
29
38
  }
30
39
  }
31
40
 
@@ -72,10 +81,27 @@ export function programError(err, logs) {
72
81
  return last ?? (typeof err === 'string' ? err : JSON.stringify(err));
73
82
  }
74
83
 
84
+ /**
85
+ * Whether a send was refused because the cluster has this very transaction already (it was sent before): the node's
86
+ * own word for it (`err`), or, from a node that gives only a sentence, its sentence. Never a program's log line: a
87
+ * dry run that failed inside a program is that program's refusal, whatever its words.
88
+ */
89
+ export const alreadyProcessed = error =>
90
+ error?.err === 'AlreadyProcessed' ||
91
+ (error?.err === undefined &&
92
+ !error?.logs?.length &&
93
+ /this transaction has already been processed/i.test(String(error?.message ?? '')));
94
+
95
+ /** Whether a send's refusal is the dry run not knowing the transaction's blockhash. */
96
+ const blockhashNotFound = error =>
97
+ error?.data?.err === 'BlockhashNotFound' || /blockhash not found/i.test(String(error?.message ?? ''));
98
+
75
99
  /** A client for one RPC URL. `fetchImpl` and `sleepImpl` are for tests. */
76
100
  export function rpcClient(url, { fetchImpl = globalThis.fetch, sleepImpl = sleep } = {}) {
77
101
  let id = 0;
78
102
  async function call(method, params = []) {
103
+ // A try that got no answer (the connection dropped, a gateway failed): a node may have taken what it carried.
104
+ let unanswered = false;
79
105
  for (let attempt = 0; ; attempt++) {
80
106
  let response;
81
107
  try {
@@ -86,6 +112,7 @@ export function rpcClient(url, { fetchImpl = globalThis.fetch, sleepImpl = sleep
86
112
  signal: AbortSignal.timeout(TIMEOUT_MS),
87
113
  });
88
114
  } catch (error) {
115
+ unanswered = true;
89
116
  if (attempt < RETRIES) {
90
117
  await sleepImpl(500 * 2 ** attempt);
91
118
  continue;
@@ -98,6 +125,7 @@ export function rpcClient(url, { fetchImpl = globalThis.fetch, sleepImpl = sleep
98
125
  continue;
99
126
  }
100
127
  // Sending the same signed transaction again is safe: it has one signature, so it lands once at most.
128
+ if (PASSING.includes(response.status)) unanswered = true;
101
129
  if (PASSING.includes(response.status) && attempt < RETRIES) {
102
130
  await sleepImpl(500 * 2 ** attempt);
103
131
  continue;
@@ -115,13 +143,25 @@ export function rpcClient(url, { fetchImpl = globalThis.fetch, sleepImpl = sleep
115
143
  await sleepImpl(500 * 2 ** attempt);
116
144
  continue;
117
145
  }
146
+ // A send refused in its dry run for a blockhash this node has not seen yet ("Blockhash not found"): the node
147
+ // that gave the blockhash was a moment ahead of this one. Asked again, it (or another) knows it. A blockhash
148
+ // that is truly gone is refused the same way each time, and the last refusal is the answer.
149
+ if (method === 'sendTransaction' && blockhashNotFound(body?.error) && attempt < RETRIES) {
150
+ await sleepImpl(500 * 2 ** attempt);
151
+ continue;
152
+ }
118
153
  // A send whose preflight failed ("Transaction simulation failed: … custom program error: 0x17b5") carries the
119
154
  // logs, where the program says why in words.
120
155
  const logs = body?.error?.data?.logs;
121
156
  const said = logs?.length
122
157
  ? `The network refused this in a dry run: ${programError(body.error.data.err, logs)}`
123
158
  : message;
124
- throw new RpcError(said, { code: body?.error?.code, logs });
159
+ throw new RpcError(said, {
160
+ code: body?.error?.code,
161
+ logs,
162
+ err: body?.error?.data?.err,
163
+ maybeTaken: method === 'sendTransaction' && unanswered,
164
+ });
125
165
  }
126
166
  return body.result;
127
167
  }
@@ -211,10 +251,17 @@ export function rpcClient(url, { fetchImpl = globalThis.fetch, sleepImpl = sleep
211
251
  };
212
252
  },
213
253
  async send(wire) {
214
- return call('sendTransaction', [
215
- toBase64(wire),
216
- { encoding: 'base64', preflightCommitment: 'confirmed', maxRetries: 3 },
217
- ]);
254
+ try {
255
+ return await call('sendTransaction', [
256
+ toBase64(wire),
257
+ { encoding: 'base64', preflightCommitment: 'confirmed', maxRetries: 3 },
258
+ ]);
259
+ } catch (e) {
260
+ // A transaction the cluster has taken already is refused as already processed: that is what a second send
261
+ // gets when the answer to the first was lost on its way back. It was sent; its signature is its first.
262
+ if (alreadyProcessed(e)) return transactionId(wire);
263
+ throw e;
264
+ }
218
265
  },
219
266
  signatureStatus,
220
267
  /** Wait until a sent transaction is confirmed; throws with the program's reason when it failed. */
@@ -5673,7 +5673,7 @@ export function check(
5673
5673
  warn(
5674
5674
  'W-AUTHORITY',
5675
5675
  'economy.keys.authority',
5676
- `This economy needs the authority while it runs, for what only the game knows: ${needs.join('; ')}. ${m.authority === 'owner' ? 'That is your wallet, approving each one.' : `That is the key ${m.authority}.`}`,
5676
+ `This economy needs the authority while it runs, for what only the game knows: ${needs.join('; ')}. ${m.authority === 'owner' ? 'That is the project’s owner key, which signs each one.' : `That is the key ${m.authority}.`}`,
5677
5677
  );
5678
5678
  const voucherKeys = [...new Set(m.grants.flatMap(g => [...(g.vouchers ?? []), ...(g.keys ?? [])]))];
5679
5679
  if (voucherKeys.length)
@@ -5802,7 +5802,7 @@ export function check(
5802
5802
  refuse(
5803
5803
  'E-TX-SIZE',
5804
5804
  paths[kind] ? `economy.${paths[kind]}${part ? `.${key}` : ''}` : 'economy',
5805
- `${named} takes ${bytes} bytes to set up in one transaction, more than the ${BUDGET} a setup transaction may hold (${MAX_TRANSACTION_BYTES}, less ${WALLET_ROOM} kept for what the owner’s wallet adds), so it could never go live.`,
5805
+ `${named} takes ${bytes} bytes to set up in one transaction, more than the ${BUDGET} a setup transaction may hold (${MAX_TRANSACTION_BYTES}, less ${WALLET_ROOM} kept for the compute unit limit and price it leads with), so it could never go live.`,
5806
5806
  fix,
5807
5807
  );
5808
5808
  }
@@ -3,7 +3,16 @@
3
3
  //
4
4
  // Rent is (bytes + 128) × 5,080 lamports (model.mjs); the loot program's own records are sized byte for byte from
5
5
  // their Borsh layout, others (a Core collection, a farm) roughly, and marked so.
6
- import { boxOrderBytes, plan as compile, contestBytes, entryShape, farmEnds, roundRentOf } from './plan.mjs';
6
+ import { priorityFee } from '../chain/programs.js';
7
+ import {
8
+ boxOrderBytes,
9
+ plan as compile,
10
+ contestBytes,
11
+ entryShape,
12
+ farmEnds,
13
+ roundRentOf,
14
+ withSetupBudget,
15
+ } from './plan.mjs';
7
16
  import {
8
17
  ACCOUNT_BYTES,
9
18
  CONTEST_ENTRY_BYTES,
@@ -63,7 +72,12 @@ export function setupCost(plan, economy) {
63
72
  const metadataLamports = accounts.reduce((n, a) => n + BigInt(a.fee ?? 0), 0n);
64
73
  const charged = plan.transactions.flatMap(t => t.steps).filter(s => !NO_FEE_STEP.test(s.id));
65
74
  const feeLamports = BigInt(charged.length) * FEES.project;
66
- const networkLamports = plan.transactions.reduce((n, t) => n + BigInt(t.signers.length) * SIGNATURE_FEE, 0n);
75
+ // The network's fee: 5,000 lamports a signature, and each transaction's own priority fee (its unit limit times the
76
+ // unit price the plan sets: 2 lamports; none for a transaction listed without its instructions).
77
+ const networkLamports = plan.transactions.reduce(
78
+ (n, t) => n + BigInt(t.signers.length) * SIGNATURE_FEE + priorityFee(t.instructions ?? []),
79
+ 0n,
80
+ );
67
81
  // Only the seeds and grant funds this plan puts in: a change to a live economy doesn't pay its seeds again.
68
82
  const steps = new Set(plan.transactions.flatMap(t => t.steps).map(s => s.id));
69
83
  const seeds = {};
@@ -101,7 +115,9 @@ export function setupCost(plan, economy) {
101
115
  notes.push(
102
116
  'The total includes the SOL put in grants’ funds, which pays their claims; what is left goes back to you when a grant closes.',
103
117
  );
104
- notes.push('The network fee is 5,000 lamports a signature; a wallet may add a priority fee of its own.');
118
+ notes.push(
119
+ `The network fee is 5,000 lamports a signature and a priority fee of ${priorityFee(withSetupBudget([]))} lamports a transaction: each sets its own compute unit limit and price, so a wallet that adds a priority fee of its own leaves it as it is.`,
120
+ );
105
121
  return {
106
122
  accounts,
107
123
  rentSol: sol(rentLamports),
@@ -489,7 +505,7 @@ export function cost(economy, { now = Math.floor(Date.now() / 1000), only = null
489
505
  export function explainCost(c) {
490
506
  const s = c.setup;
491
507
  const lines = [
492
- `Going live takes ${s.transactions} transaction${s.transactions === 1 ? '' : 's'} (${s.approvals} wallet approval${s.approvals === 1 ? '' : 's'}) and about ${s.totalSol} SOL:`,
508
+ `Going live takes ${s.transactions} transaction${s.transactions === 1 ? '' : 's'} and about ${s.totalSol} SOL:`,
493
509
  ];
494
510
  lines.push(
495
511
  ` rent ${s.rentSol} SOL for ${s.accounts.length} accounts; program fees ${s.programFeesSol} SOL; network fees ${s.networkFeesSol} SOL${
@@ -560,7 +560,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
560
560
  ]);
561
561
  const authority = play.filter(s => s.signer.startsWith('authority'));
562
562
  section(
563
- `What only the authority signs while it runs (${m.authority === 'owner' ? 'your wallet' : `the key ${m.authority}`})`,
563
+ `What only the authority signs while it runs (${m.authority === 'owner' ? 'the project’s owner key' : `the key ${m.authority}`})`,
564
564
  authority.map(s => `${s.says} (${s.part}, ${s.ix}).`),
565
565
  );
566
566
  if (authority.length)
@@ -571,7 +571,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
571
571
  const s = c.setup;
572
572
  lines.push('', 'What setting it up costs');
573
573
  lines.push(
574
- `- About ${num(s.totalSol)} SOL in all, in ${plural(s.transactions, 'transaction')} (${plural(s.approvals, 'wallet approval')}): rent ${num(s.rentSol)} SOL for ${plural(s.accounts.length, 'account')}, program fees ${num(s.programFeesSol)} SOL, network fees ${num(s.networkFeesSol)} SOL${
574
+ `- About ${num(s.totalSol)} SOL in all, in ${plural(s.transactions, 'transaction')}: rent ${num(s.rentSol)} SOL for ${plural(s.accounts.length, 'account')}, program fees ${num(s.programFeesSol)} SOL, network fees ${num(s.networkFeesSol)} SOL${
575
575
  s.metadataFeesSol ? `, Token Metadata’s fees ${num(s.metadataFeesSol)} SOL (a token’s name on chain)` : ''
576
576
  }${
577
577
  Object.keys(s.seeds).length
@@ -613,7 +613,7 @@ export function explainEconomy(economy, { now = Math.floor(Date.now() / 1000) }
613
613
  );
614
614
  lines.push(
615
615
  '',
616
- 'Devnet only: nothing here is real money. Going live is your approval in your own wallet: no one else signs, and no key is ever shared.',
616
+ 'Devnet only: nothing here is real money. Going live is signed on your own machine by the project’s key file, which is what “your wallet” means above. The command first prints everything it would send and sends nothing; it sends only when run again with the code it printed, which your agent is told to do only after your yes. No seed phrase or wallet key is ever asked for.',
617
617
  );
618
618
  return {
619
619
  ok: true,
@@ -230,7 +230,7 @@ export const CANNOT = [
230
230
  why: 'loot isn’t a multisig, but any wallet can receive and any key can be the spending key',
231
231
  nearest: [
232
232
  'payments and sales go to a multisig vault: `"treasury": "wallet:<vault>"` or a `wallet:<vault>` share; spending from it needs the vault’s approvals in the multisig’s own app',
233
- 'the multisig vault as `keys.authority`, so moving the game’s pools needs its approvals (sent from the multisig’s own app, not the approve page)',
233
+ 'the multisig vault as `keys.authority`, so moving the game’s pools needs its approvals (sent from the multisig’s own app, not by `xsolana approve`)',
234
234
  ],
235
235
  },
236
236
  {
@@ -431,7 +431,7 @@ export function guideText(only) {
431
431
  return [
432
432
  only
433
433
  ? `The part “${only}” of a loot economy (* = required):`
434
- : `A loot economy is one JSON spec (xsolana.json) on the shared loot program ${IDL.metadata.version}, devnet only. Nobody writes a contract: the spec is checked, simulated, and put live by the owner approving in their own wallet. Its parts (* = required field):`,
434
+ : `A loot economy is one JSON spec (xsolana.json) on the shared loot program ${IDL.metadata.version}, devnet only. Nobody writes a contract: the spec is checked, simulated, and put live on the owner’s own machine, signed by the project’s key file once they have said yes. Its parts (* = required field):`,
435
435
  ...parts.flatMap(p => [
436
436
  `\n${p.part} (${p.type}): ${p.says}${p.atPlayTime ? `\n At play time: ${p.atPlayTime}.` : ''}`,
437
437
  ...p.fields.map(fieldLine),
@@ -189,4 +189,4 @@ export function changedParts(economy) {
189
189
  * of that is decided when the change is approved.
190
190
  */
191
191
  export const renamesNote = renames =>
192
- `The spec changes what ${renames.join(', ')} ${renames.length === 1 ? 'is' : 'are'} called. A token’s name on chain is its name record: approving reads it, and writes the new name, ticker and link there when the token has a record its owner can still change (and nothing when the record says them already). A token that went live before setups named tokens has no record and can’t be given one, and a record handed on, made unchangeable or given a royalty outside these tools is left as it is: the approval page says so, sends nothing for that token, and the change stays listed here until the spec gives its old name back.`;
192
+ `The spec changes what ${renames.join(', ')} ${renames.length === 1 ? 'is' : 'are'} called. A token’s name on chain is its name record: approving reads it, and writes the new name, ticker and link there when the token has a record its owner can still change (and nothing when the record says them already). A token that went live before setups named tokens has no record and can’t be given one, and a record handed on, made unchangeable or given a royalty outside these tools is left as it is: approving says so, sends nothing for that token, and the change stays listed here until the spec gives its old name back.`;
@@ -34,6 +34,7 @@ import {
34
34
  createAssociatedTokenAccountIdempotent,
35
35
  syncNative,
36
36
  transferLamports,
37
+ withComputeBudget,
37
38
  } from '../chain/programs.js';
38
39
  import {
39
40
  createAccountIx,
@@ -43,7 +44,7 @@ import {
43
44
  setAuthorityIx,
44
45
  transferCheckedIx,
45
46
  } from '../chain/token.js';
46
- import { compileMessage, transactionSize } from '../chain/tx.js';
47
+ import { compileMessage, MAX_TRANSACTION_BYTES, transactionSize } from '../chain/tx.js';
47
48
  import { sha256 } from '../chain/sha256.js';
48
49
  import { utf8 } from '../chain/bytes.js';
49
50
  import * as loot from '../client/index.js';
@@ -1878,13 +1879,33 @@ export const stepBytes = (instructions, ctx) =>
1878
1879
  const budgeted = (instructions, compute) =>
1879
1880
  compute ? [computeUnitLimit(MAX_COMPUTE_UNITS), ...instructions] : instructions;
1880
1881
 
1881
- /** The bytes of a transaction of `step` alone, as `pack` sends it (its compute limit included). */
1882
+ /** The bytes of a transaction of `step` alone, as `pack` holds it against the budget (a box's compute limit included). */
1882
1883
  export const stepAloneBytes = (step, ctx) => stepBytes(budgeted(step.build(ctx), Boolean(step.compute)), ctx);
1883
1884
 
1884
1885
  /**
1885
- * Packs steps into transactions under the size budget, one signer set at a time; each step whole. A transaction that
1886
- * carries a step needing more compute than an instruction has by default (`compute`: a box's write) asks for all a
1887
- * transaction can have, first.
1886
+ * The compute unit price every setup transaction sets, in micro-lamports a unit: 1, the least that is not 0 (a wallet
1887
+ * may read 0 as no price set). With the unit limit below it comes to 1.4 lamports, charged as 2 lamports a transaction
1888
+ * on top of the 5,000 a signature.
1889
+ */
1890
+ export const SETUP_UNIT_PRICE = 1n;
1891
+
1892
+ /**
1893
+ * A setup transaction's instructions as the owner's wallet is handed them: led by a compute unit limit and a compute
1894
+ * unit price, each once. A wallet that adds its own priority fee (Phantom does) adds it only to a transaction that
1895
+ * sets neither, and the approval page sends a transaction only as it prepared it, so every setup transaction sets both
1896
+ * itself. The limit is all a transaction can have (1,400,000 units): nothing here measures what a step takes, a box's
1897
+ * write needs it, and no step may fail for want of compute. A limit or a price the instructions already carry is
1898
+ * kept.
1899
+ */
1900
+ export const withSetupBudget = instructions =>
1901
+ withComputeBudget(instructions, { units: MAX_COMPUTE_UNITS, microLamports: SETUP_UNIT_PRICE });
1902
+
1903
+ /**
1904
+ * Packs steps into transactions under the size budget, one signer set at a time; each step whole. Every transaction
1905
+ * leads with its compute budget (`withSetupBudget`): `BUDGET` keeps 64 bytes of a transaction free, and the two
1906
+ * instructions take 52 of them (12 where a box's write, `compute`, had its limit counted with the steps already). So
1907
+ * the steps pack as they did before the plan set a price, and `bytes`, the size of the transaction as it is sent, is
1908
+ * held to what one transaction carries.
1888
1909
  */
1889
1910
  export function pack(steps, ctx) {
1890
1911
  const transactions = [];
@@ -1922,7 +1943,14 @@ export function pack(steps, ctx) {
1922
1943
  };
1923
1944
  transactions.push(current);
1924
1945
  }
1925
- for (const t of transactions) t.bytes = sizeOf(t.instructions);
1946
+ for (const t of transactions) {
1947
+ t.instructions = withSetupBudget(t.instructions);
1948
+ t.bytes = sizeOf(t.instructions);
1949
+ if (t.bytes > MAX_TRANSACTION_BYTES)
1950
+ throw new Error(
1951
+ `The steps “${t.steps.map(s => s.says).join('”, “')}” take ${t.bytes} bytes with their compute budget: too big for one transaction.`,
1952
+ );
1953
+ }
1926
1954
  return transactions;
1927
1955
  }
1928
1956
 
@@ -670,7 +670,7 @@ export function review(economy, { now = Math.floor(Date.now() / 1000) } = {}) {
670
670
  m.authority === 'owner' ? 'info' : 'low',
671
671
  'economy',
672
672
  `${owner[0].toUpperCase()}${owner.slice(1)} alone changes every price, rate, grant, limit and voucher key (publishing again), and closes parts. ${m.authority === 'owner' ? 'It is also the authority, which' : `The authority, ${authority},`} mints the project’s tokens, moves what pools hold, names contest results and releases holds, with no caps. ${m.treasuryWallet ? `What sales leave goes to the wallet ${m.treasuryWallet}.` : 'What sales leave goes to the owner’s wallet (the treasury).'} Money held for a record (a farm’s stakes and rewards, a pot, an order) leaves only by its record’s rules: neither key pays it out.`,
673
- 'Keep these keys in a hardware wallet or a multisig; never on a server that players can reach, unless it is an authority made for that server.',
673
+ 'Keep these keys safe: on devnet, the key file the setup was signed with, readable by you alone and never pasted anywhere; with real money, a hardware wallet or a multisig. Never on a server that players can reach, unless it is an authority made for that server.',
674
674
  );
675
675
  for (const w of warned('W-MINT-POWERS')) {
676
676
  const key = /^economy\.tokens\.([^.]+)/.exec(w.where)?.[1];
@@ -1824,7 +1824,7 @@
1824
1824
  "signedBy": {
1825
1825
  "enum": ["page", "server"],
1826
1826
  "default": "page",
1827
- "description": "page: the authority approves each one in its own wallet. server: a server you run holds the authority key (keys.authority is its public address)."
1827
+ "description": "page: the authority signs each one itself (a script with its key file, or its browser wallet). server: a server you run holds the authority key (keys.authority is its public address)."
1828
1828
  },
1829
1829
  "proof": {
1830
1830
  "type": "object",
@@ -11,8 +11,9 @@
11
11
  // - instruction data is sized byte for byte from the program's IDL (idl.mjs), and other programs' data from their
12
12
  // documented layouts at its longest;
13
13
  // - no address lookup tables are assumed.
14
- // A wallet may add its own compute-budget instructions (a price and a limit: about 52 bytes), so the plan keeps
15
- // WALLET_ROOM free below the network's 1,232-byte limit.
14
+ // A transaction's compute budget instructions (a unit limit and a unit price) take 52 bytes. A setup transaction
15
+ // leads with its own (plan.mjs `withSetupBudget`), and a player's wallet may add its own to a transaction that sets
16
+ // none. So an estimate keeps WALLET_ROOM free below the network's 1,232-byte limit.
16
17
 
17
18
  export const MAX_TRANSACTION_BYTES = 1232;
18
19
  export const WALLET_ROOM = 64;
package/js/mcp/server.mjs CHANGED
@@ -10,18 +10,18 @@ import { PACKAGE_VERSION, PROGRAM_INFO, REMOTE_TOOLS, tools, VERSION } from './t
10
10
 
11
11
  export const INSTRUCTIONS = [
12
12
  'loot is one shared Solana program for game and community economies (shops, loot boxes, lotteries, auctions, staking farms, crafting, contests, schedules, perks for holders of any NFT, capped rewards). An economy is a JSON spec, not a new smart contract: never write Anchor or Rust for what loot covers.',
13
- 'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets); xsolana_save; xsolana_publish when they say to put it live (they approve in their own wallet: you never sign or ask for a key).',
13
+ 'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec; xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets); xsolana_save; xsolana_publish when they say to put it live. Going live is signed by a key file on their own machine (xsolana keygen makes one; never ask for, read or print a key) with xsolana approve --keypair, which prints everything it would send and sends nothing until it is run again with the code it printed: do that only after they have read the printout and said yes. That key owns the project for good: a change to an economy that is already live is signed by the key that owns it, never by a new one. A browser wallet’s signing page (approve --browser) only if they ask for one, and they run it themselves.',
14
14
  `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
15
15
  ].join(' ');
16
16
 
17
17
  /**
18
18
  * What a hosted server (js/mcp/http.mjs) tells the agent: the same work, with the spec passed whole in every call,
19
- * and going live on the person's own computer, since a hosted server can neither write their files nor open a page there.
19
+ * and going live on the person's own computer, since a hosted server can neither write their files nor sign there.
20
20
  */
21
21
  export const REMOTE_INSTRUCTIONS = [
22
22
  INSTRUCTIONS.split(' Start with')[0],
23
23
  'Start with xsolana_guide, then xsolana_examples with the person’s own words; write the spec and pass it whole as "spec" to every tool (this hosted server reads no files); xsolana_check after every change; xsolana_simulate and xsolana_explain before telling them what it does; xsolana_review before going live (fix its high findings, say honestly what each voucher server checks, and tell the person what a cheater gets).',
24
- `To go live, give the person the spec as a file named xsolana.json and this command, run in its folder: npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json. It opens a page on their own computer where they approve each transaction in their own wallet. Nobody else signs, and never ask for a key. (xsolana_save and xsolana_publish, which tool texts may name, run only in the local server.)`,
24
+ `To go live, the spec goes in a file named xsolana.json on the person’s own computer and is signed there by a key file. In that folder: npx -y @xgamesfun/solana@${PACKAGE_VERSION} keygen ~/.config/xsolana/<name>.json makes the key file (it prints the address, never the key; the person sends that address devnet SOL), then npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve xsolana.json --keypair <that file> prints everything that would be sent and sends nothing. When the person has read that and said yes, the same command with --yes <the code it printed> sends it. Run them yourself if you have a terminal there; otherwise hand them to the person. Never ask for, read or print a key. Say once what the key file is: the project’s owner for good (whoever can read it controls the project; they back it up themselves; nothing restores a lost one) and, unless the spec names a treasury, where what the economy earns lands. A change to an economy that is already live is signed by the key that owns it: never make a new key for it. A browser wallet’s signing page (the same approve command with --browser in place of --keypair), which the person runs themselves, only if they ask for one or a browser wallet owns the project. (xsolana_save and xsolana_publish, which tool texts may name, run only in the local server.)`,
25
25
  `Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
26
26
  ].join(' ');
27
27
 
package/js/mcp/tools.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // The xsolana tools: what an agent needs to turn a person's idea into an economy on the shared loot program, check it,
2
- // simulate it, price it, explain it, save it and hand the person the step that puts it live in their own wallet.
2
+ // simulate it, price it, explain it, save it and say how it is put live: signed by a key file on the person's own
3
+ // machine, once they have read what would be sent and said yes.
3
4
  // Each tool is { name, description, inputSchema, annotations, handler(input, ctx) → { text, ...data } }; the server
4
5
  // (server.mjs) and the terminal (`xsolana tool`) run them the same way. No tool signs, holds a key or talks to a wallet.
5
6
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
@@ -48,15 +49,30 @@ export function plainName(rel, paths = path) {
48
49
  const NOT_PLAIN = rel => `${JSON.stringify(String(rel))} is not a plain file name`;
49
50
  const RENAME = 'use letters, digits and . _ - (and / for a folder), as in xsolana.json';
50
51
  /**
51
- * The command an owner runs to approve going live in their own wallet, pinned to this package's exact version, with
52
- * the spec's file (relative to the project folder, by `paths`' rules: the system's own unless given) as one plain
53
- * word. A name that is not plain throws.
52
+ * The command that puts an economy live, pinned to this package's exact version, with the spec's file (relative to the
53
+ * project folder, by `paths`' rules: the system's own unless given) as one plain word. A name that is not plain
54
+ * throws. How it is signed goes after it: `--keypair <key file>` (the usual way), or `--browser` when the person
55
+ * asks for a browser wallet.
54
56
  */
55
57
  export function approveCommand(file = '', paths = path) {
56
58
  const name = file ? plainName(file, paths) : '';
57
59
  if (name === null) throw new Error(`${NOT_PLAIN(file)}: rename it (${RENAME}).`);
58
60
  return `npx -y @xgamesfun/solana@${PACKAGE_VERSION} approve${name ? ` ${name}` : ''}`;
59
61
  }
62
+ /**
63
+ * Where a project's key file is kept unless the person names another place: outside any repository, under the
64
+ * economy's own name (`~` is the home folder, in any shell: the commands read it themselves).
65
+ */
66
+ export function keyFileFor(name) {
67
+ const slug = String(name ?? '')
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, '-')
70
+ .replace(/^-+|-+$/g, '')
71
+ .slice(0, 40);
72
+ return `~/.config/xsolana/${slug || 'project'}.json`;
73
+ }
74
+ /** The command that makes a project's key file (it prints the key's address, never the key). */
75
+ export const keygenCommand = keyFile => `npx -y @xgamesfun/solana@${PACKAGE_VERSION} keygen ${keyFile}`;
60
76
  export const CLUSTER = 'devnet';
61
77
  export const DEVNET_RPC = 'https://api.devnet.solana.com';
62
78
  /** What every answer carries, so an agent never has to remember the program's address. */
@@ -235,7 +251,7 @@ function flagsToTell(economy, now) {
235
251
  const flags = legalFlags(check(economy, { now }));
236
252
  return flags.length
237
253
  ? [
238
- "If you haven't told the person these yet, say each once, in a plain sentence (the approval page shows them too, and asks them to tick that they read them; after that, only when they ask):",
254
+ "If you haven't told the person these yet, say each once, in a plain sentence (what `approve` prints before anything is sent shows them too; after that, only when they ask):",
239
255
  ...flags.map(f => `- ${f.says}`),
240
256
  '',
241
257
  ]
@@ -422,7 +438,7 @@ export const tools = [
422
438
  name: 'xsolana_cost',
423
439
  annotations: READ,
424
440
  description:
425
- 'What going live costs (each account’s rent, the program’s fees, network fees, the seeds, the transactions and wallet approvals it takes) and what each action costs whoever signs it while it runs.',
441
+ 'What going live costs (each account’s rent, the program’s fees, network fees, the seeds, the transactions it takes) and what each action costs whoever signs it while it runs.',
426
442
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
427
443
  async handler(input, ctx) {
428
444
  const { economy, from } = await load(input, ctx);
@@ -565,7 +581,7 @@ export const tools = [
565
581
  {
566
582
  name: 'xsolana_publish',
567
583
  annotations: { ...READ, readOnlyHint: true },
568
- description: `When the person says to put it live: returns what going live does (every step, in order, and its cost) and the one command they run themselves to approve it in their own wallet (\`${approveCommand()}\`, pinned to this exact version: give it as it is): it opens a local page where they connect a devnet wallet and approve each transaction. You never sign, never ask for a key, and never run the approval for them.`,
584
+ description: `When the person says to put it live: returns what going live does (every step, in order, and its cost) and the commands that do it (each starts npx -y @xgamesfun/solana@${PACKAGE_VERSION} for this exact version: give them as they are). Going live is signed by a key file on the person’s own machine: \`keygen\` makes one for the project (it prints the address, never the key), and \`approve … --keypair <file>\` prints everything that would be sent and sends nothing until it is run again with the code it printed, which you do only after the person has read that printout and said yes. A live economy is changed only by the key that owns it: never make a new key for one. Never ask for, read or print a key, and open no signing page: the browser-wallet page (\`approve … --browser\`) is for a person who asks for one, or whose browser wallet owns the project, and they run it themselves.`,
569
585
  inputSchema: { type: 'object', properties: SOURCE, additionalProperties: false },
570
586
  async handler(input, ctx) {
571
587
  const { economy, from, file } = await load(input, ctx);
@@ -608,21 +624,41 @@ export const tools = [
608
624
  steps: [],
609
625
  text: `Not published: ${NOT_PLAIN(target)}, and the approve command carries the file's name as it is. Ask the person to rename it (${RENAME}), then run xsolana_publish again with the new name.${footer}`,
610
626
  };
611
- const command = approveCommand(target);
627
+ const live = Boolean(change.scope.live);
628
+ const keyFile = keyFileFor(economy.name);
629
+ // (a live project is signed only by the key that owns it: no command here makes another)
630
+ const keygen = live ? null : keygenCommand(keyFile);
631
+ const browserCommand = `${approveCommand(target)} --browser`;
632
+ const command = `${approveCommand(target)} --keypair ${keyFile}`;
612
633
  const text = [
613
634
  ...flagsToTell(economy, nowOf(ctx)),
614
- `${change.scope.live ? 'Publishing this change to the live economy' : 'Going live'}: ${renaming ? 'at most ' : ''}${plural(p.transactions.length, 'transaction')}, ${plural(p.approvals, 'wallet approval')}, about ${c.setup.totalSol} SOL on devnet:`,
635
+ `${change.scope.live ? 'Publishing this change to the live economy' : 'Going live'}: ${renaming ? 'at most ' : ''}${plural(p.transactions.length, 'transaction')}, about ${c.setup.totalSol} SOL on devnet:`,
615
636
  ...steps.map((s, i) => `${i + 1}. ${s}`),
616
637
  '',
617
- `To put it live, the person runs this in a terminal in the project folder (not you): ${command}`,
618
- 'It opens a page on their own computer; they connect a wallet set to devnet and approve. What lands is written back into the file.',
619
- 'The page also says the program is not independently audited and comes as is, with no warranty; they use it at their own risk, and making sure the economy is legal where it is offered is up to them (DISCLAIMER.md in the package). They tick that they read it, so it needs no repeating unless they ask.',
620
- ...(file ? [] : [`(Save it first with xsolana_save: the approval reads ${DEFAULT_FILE}.)`]),
638
+ `${live ? 'To publish it' : 'To put it live'}, in a terminal in the project folder (you run these; nothing opens, and nothing is sent before the person’s yes):`,
639
+ ...(live
640
+ ? [
641
+ `1. Only the key that owns the project can change it: the key file it went live with (${keyFile} if it was made there; give the file’s own path if it is elsewhere). If there is no such file, a browser wallet owns it: use the --browser command below. Never make a new key for a live project (a new key can change nothing), and never remove the file’s live section because a key was refused. If you can’t tell, ask the person how it went live. The owner’s address needs about ${c.setup.totalSol} SOL of devnet SOL.`,
642
+ ]
643
+ : [
644
+ `1. A key file for this project, unless it has one: ${keygen}`,
645
+ ' It prints the key’s address, never the key. The key file is the project’s owner: whoever can read it controls the project, and which key goes live is for good (every later change is signed by it; these tools can’t hand a project to another owner). It stays where it is: never in a chat, a spec, a repository or a page, and you never open, read or print it. Tell the person to back the file up themselves: if it is lost, nothing can change the economy or move what its address holds.',
646
+ ` Everything the setup takes from the owner must be at that address first: the devnet SOL (about ${c.setup.totalSol} SOL here; https://faucet.solana.com gives some), any seed in a coin that already exists, and every NFT a stash, a raffle or an auction names (a collection they already have needs that address as its update authority). If they would rather not move those, offer the browser wallet.`,
647
+ ' The steps above call the owner’s address “your wallet”: with a key file, that is the key’s address. Unless the spec names a treasury, what sales leave lands there, and it signs what only the authority signs. If the economy takes money in, ask for the public address of a wallet the person holds and set "treasury": "wallet:<address>" first: it can’t be changed through the spec once live.',
648
+ ]),
649
+ `2. ${command}`,
650
+ ' It prints every step with what it pays and trusts, the warnings, the notice (the program is not independently audited and comes as is, with no warranty; they use it at their own risk, and making sure the economy is legal where it is offered is up to them: DISCLAIMER.md in the package) and a code. It sends nothing.',
651
+ '3. Show the person that printout and ask. When they say yes, run the same command with --yes <code>: the code stands for exactly what was printed, and anything else is refused. What lands is written back into the file. If it stops part way, run it again without --yes: it prints what is left and a new code. Go on with that code; if it shows a step or a warning they have not seen, ask again.',
652
+ `${live ? 'For a project a browser wallet owns' : 'Only if the person asks to sign in a browser wallet instead'}: give them this to run in their own terminal: ${browserCommand} (it serves a page on their own computer where they connect a wallet set to devnet and approve, ${plural(p.approvals, 'approval')}, and keeps running until the page says it is live). Don’t run or open it otherwise.${live ? ' A project a key file owns can’t be signed in a browser wallet.' : ''}`,
653
+ ...(file ? [] : [`(Save it first with xsolana_save: \`approve\` reads ${DEFAULT_FILE}.)`]),
621
654
  ...(change.notes ? [change.notes.trim()] : []),
622
655
  ].join('\n');
623
656
  return {
624
657
  ok: true,
625
658
  command,
659
+ ...(keygen ? { keygen } : {}),
660
+ keyFile,
661
+ browserCommand,
626
662
  transactions: p.transactions.length,
627
663
  approvals: p.approvals,
628
664
  setupSol: c.setup.totalSol,
@@ -861,6 +897,7 @@ export const tools = [
861
897
  const text = [
862
898
  `loot ${VERSION} on ${CLUSTER}: program ${LOOT_PROGRAM}. Never write another address from memory: take it from this package.`,
863
899
  'Package: npm install @xgamesfun/solana (plain ES modules, no Solana SDK needed; works in the browser).',
900
+ ' What only the project’s owner or authority signs (a contest’s result, a payout, a schedule’s run) is not a page’s: a script on the owner’s machine signs it with the project’s key file, `readKeyFile(file)` from "@xgamesfun/solana/keyfile" (an address and a way to sign, never the key; it is also a `wallet` for solanaApp). The skill’s references/after-launch.md has the script.',
864
901
  ' import { solanaApp, walletStandardSigner } from "@xgamesfun/solana/play";',
865
902
  ' const app = solanaApp({ spec /* the live xsolana.json */, rpcUrl, wallet: walletStandardSigner(wallet, account) });',
866
903
  ' app.shop(), app.buy(key, { listing }), app.boxes(), app.openBox(key, { listing }), app.pay(key) (or app.pay(key, { chosenRecipient })),',
package/js/play/index.js CHANGED
@@ -37,12 +37,14 @@ import {
37
37
  requestHeapFrame,
38
38
  syncNative,
39
39
  transferLamports,
40
+ withComputeBudget,
40
41
  withSolWrap,
41
42
  } from '../chain/programs.js';
42
43
  import { checkCluster, notMainnet, rpcClient } from '../chain/rpc.js';
43
44
  import { extension, mayStartFrozen, mintPowers, resolveTransfers } from '../chain/token2022.js';
44
45
  import {
45
46
  compileFitting,
47
+ compileKeepingKind,
46
48
  closeTableIx,
47
49
  deactivateTableIx,
48
50
  readTable,
@@ -74,6 +76,9 @@ export {
74
76
 
75
77
  // The options of a call made without any: read-only, and typed so the generated declarations take any option.
76
78
  /** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
79
+ /** The compute units a sponsored step asks for: all a transaction can have. It sets a price of 0, so they cost nothing. */
80
+ const SPONSORED_UNITS = 1_400_000;
81
+
77
82
  const LOTTERY_STEPS = new Set(['reveal', 'cancel-draw', 'pull', 'payout']);
78
83
  /** The claims that are one transaction of the wallet's: `claimAll` signs them together. */
79
84
  const BATCHED = new Set(['contest', 'release', 'lookup-table', 'close-leaf', ...LOTTERY_STEPS]);
@@ -799,17 +804,26 @@ export function solanaApp({
799
804
  const { blockhash, lastValidBlockHeight } = await client.latestBlockhash();
800
805
  // Lent the most, the step is tried; then sent lent the same, the player giving back last all but what it costs in
801
806
  // fees and rent: a step can need more SOL midway than it costs (a wrapped-SOL account it opens and closes).
802
- const compile = (back = 0n) =>
803
- compileFitting({
807
+ // It leads with a compute unit limit (all a transaction can have) and a unit price of 0, the price a sponsor
808
+ // pays unless its server says more: a wallet that adds its own priority fee only where a transaction sets none
809
+ // (Phantom does, to one with no signature on it yet) is then handed one it signs as it stands, and the sponsor's
810
+ // server, which refuses a price it did not agree to, takes it. A step keeps the kind of transaction it had: one
811
+ // that fits a legacy transaction stays one (without the two where they would not fit), and only a step that
812
+ // needed the project's lookup tables before goes as v0.
813
+ const compile = (back = 0n) => {
814
+ const list = [
815
+ sponsorTopUp(sponsor.address, me, sponsorMax),
816
+ ...instructions,
817
+ ...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
818
+ ];
819
+ return compileKeepingKind({
804
820
  payer: sponsor.address,
805
821
  blockhash,
806
- instructions: [
807
- sponsorTopUp(sponsor.address, me, sponsorMax),
808
- ...instructions,
809
- ...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
810
- ],
822
+ led: withComputeBudget(list, { units: SPONSORED_UNITS, microLamports: 0n }),
823
+ plain: list,
811
824
  tables,
812
825
  });
826
+ };
813
827
  const tried = compile();
814
828
  if (!tried) return null;
815
829
  const addresses = namedIn([sponsorTopUp(sponsor.address, me, 0n), ...instructions]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.27.1",
3
+ "version": "0.27.2",
4
4
  "mcpName": "io.github.XGamesFun/solana-kit",
5
5
  "description": "Loot boxes, gacha, shops, lotteries, auctions, NFT staking, crafting and rewards on Solana without writing a smart contract: one shared program, its JS client, a CLI, and an MCP server and skill so Claude Code, Codex or any AI agent can design, check, simulate and launch an economy.",
6
6
  "license": "Apache-2.0",
@@ -70,6 +70,10 @@
70
70
  "types": "./types/results/index.d.ts",
71
71
  "default": "./js/results/index.js"
72
72
  },
73
+ "./keyfile": {
74
+ "types": "./types/approve/keyfile.d.mts",
75
+ "default": "./js/approve/keyfile.mjs"
76
+ },
73
77
  "./idl.json": "./js/client/idl/loot.json",
74
78
  "./package.json": "./package.json",
75
79
  "./release.json": "./release.json",
package/release.json CHANGED
@@ -4,5 +4,5 @@
4
4
  "sbfBytes": 1794664,
5
5
  "sbfSha256": "3b11c460548a4fec865fd902431a94ac13a71c3a1013e4516783515e5d266bd6",
6
6
  "idlSha256": "62364c5fd5be8aa001f4d72fb87b121b045dea07668f9b5ec9856d323d6cf445",
7
- "clientSha256": "179b7ff59398a39cce70df059c1781e91a3d0366791797b368d8030169a507ad"
7
+ "clientSha256": "7790b667386bb981218acc8621088b2f7799c92347193b3a97d59b9332c4ed29"
8
8
  }