@xgamesfun/solana 0.25.1 → 0.25.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -12
- package/bin/xsolana.mjs +10 -2
- package/js/client/index.js +1 -0
- package/js/client/kit.js +36 -0
- package/js/mcp/http.mjs +136 -0
- package/js/mcp/server.mjs +33 -9
- package/js/mcp/tools.mjs +24 -1
- package/js/play/index.js +116 -2
- package/js/play/sponsor.js +302 -0
- package/package.json +36 -9
- package/release.json +1 -1
- package/skills/xsolana-economy/SKILL.md +6 -3
- package/skills/xsolana-economy/references/apps.md +28 -0
- package/types/client/index.d.ts +1 -0
- package/types/client/kit.d.ts +49 -0
- package/types/economy/plan.d.mts +14 -14
- package/types/mcp/http.d.mts +30 -0
- package/types/mcp/server.d.mts +321 -0
- package/types/mcp/tools.d.mts +9 -1
- package/types/play/index.d.ts +9 -7
- package/types/play/sponsor.d.ts +149 -0
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
# solana-kit
|
|
1
|
+
# solana-kit: Solana game and NFT economies without a smart contract
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Loot boxes and gacha, shops, lotteries and raffles, auctions, NFT staking farms, crafting, quests and capped rewards on
|
|
4
|
+
Solana, written as a JSON spec instead of a new Anchor program. They all run on **loot**, one shared, open Solana
|
|
5
|
+
program, and an AI coding agent (Claude Code, Codex, Cursor or any MCP client) can design, check, simulate and price
|
|
6
|
+
one from a sentence. This repository is the npm package `@xgamesfun/solana`:
|
|
5
7
|
|
|
6
8
|
- a JavaScript client with no Solana SDK (`@xgamesfun/solana/client`), a player module for apps (`/play`) and a results
|
|
7
9
|
client (`/results`);
|
|
@@ -104,14 +106,29 @@ codex plugin marketplace add XGamesFun/solana-kit
|
|
|
104
106
|
codex plugin add xsolana@xgamesfun
|
|
105
107
|
```
|
|
106
108
|
|
|
109
|
+
**Chat apps** (claude.ai, ChatGPT, Claude Desktop): add `https://xgames.fun/mcp` as a custom connector (an MCP server
|
|
110
|
+
by URL). It is the hosted server (below, "Over HTTP"): nothing to install, and going live stays your own approval on your
|
|
111
|
+
computer. More at [xgames.fun/solana](https://xgames.fun/solana).
|
|
112
|
+
|
|
107
113
|
**Any agent with MCP** (Cursor, opencode, Cline…): run the server with
|
|
108
|
-
`npx -y --package=@xgamesfun/solana@0.25.
|
|
114
|
+
`npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp`. Or let `npx -y @xgamesfun/solana@0.25.3 connect` register it, with
|
|
109
115
|
the skill, for Claude Code and Codex.
|
|
110
116
|
|
|
117
|
+
**The skill alone, in any agent that reads skills** (Claude Code, Codex, Copilot, Gemini CLI, Cursor, Windsurf, Cline,
|
|
118
|
+
opencode…): `npx skills add XGamesFun/solana-kit` finds it and installs it for the agents you have. Without the MCP
|
|
119
|
+
server it runs the same tools through `npx`. Each GitHub release also carries `xsolana-economy.zip`, the skill as one
|
|
120
|
+
file for apps that take a skill as an upload (the Claude and ChatGPT desktop apps).
|
|
121
|
+
|
|
122
|
+
**Over HTTP**: `npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp --http` serves the tools that read nothing but
|
|
123
|
+
their arguments (guide, examples, check, simulate, cost, explain, review, status, client) at
|
|
124
|
+
`http://127.0.0.1:8787/mcp`, MCP's Streamable HTTP transport. `@xgamesfun/solana/mcp/http` exports the same handler
|
|
125
|
+
(`mcpHttp(request) → response`) for a hosted endpoint: it reads no files, and the agent hands the person the spec and the
|
|
126
|
+
`approve` command to go live.
|
|
127
|
+
|
|
111
128
|
Then ask for an economy, for example _"a fishing game where each cast costs 0.02 SOL, with a rare golden fish and a
|
|
112
129
|
monthly draw"_.
|
|
113
130
|
|
|
114
|
-
Going live is always yours: `npx -y @xgamesfun/solana@0.25.
|
|
131
|
+
Going live is always yours: `npx -y @xgamesfun/solana@0.25.3 approve` opens a page on your computer where you connect a
|
|
115
132
|
devnet wallet and approve each transaction. The agent never signs and never asks for a key.
|
|
116
133
|
|
|
117
134
|
## Use it from a terminal
|
|
@@ -119,13 +136,13 @@ devnet wallet and approve each transaction. The agent never signs and never asks
|
|
|
119
136
|
Every tool runs from the CLI too, on a spec file (`xsolana.json` by default):
|
|
120
137
|
|
|
121
138
|
```bash
|
|
122
|
-
npx -y @xgamesfun/solana@0.25.
|
|
123
|
-
npx -y @xgamesfun/solana@0.25.
|
|
124
|
-
npx -y @xgamesfun/solana@0.25.
|
|
125
|
-
npx -y @xgamesfun/solana@0.25.
|
|
126
|
-
npx -y @xgamesfun/solana@0.25.
|
|
127
|
-
npx -y @xgamesfun/solana@0.25.
|
|
128
|
-
npx -y @xgamesfun/solana@0.25.
|
|
139
|
+
npx -y @xgamesfun/solana@0.25.3 examples shop with loot boxes # the nearest worked examples
|
|
140
|
+
npx -y @xgamesfun/solana@0.25.3 check xsolana.json # refusals and warnings
|
|
141
|
+
npx -y @xgamesfun/solana@0.25.3 simulate xsolana.json # where the money goes, day by day
|
|
142
|
+
npx -y @xgamesfun/solana@0.25.3 cost xsolana.json # what going live and each action cost
|
|
143
|
+
npx -y @xgamesfun/solana@0.25.3 explain xsolana.json # the economy in plain words
|
|
144
|
+
npx -y @xgamesfun/solana@0.25.3 review xsolana.json # what a cheater gets on each route (exits 1 on a high finding)
|
|
145
|
+
npx -y @xgamesfun/solana@0.25.3 approve xsolana.json # go live, approved in your own wallet
|
|
129
146
|
```
|
|
130
147
|
|
|
131
148
|
`xsolana help` lists every command. Every command names the exact version, so `npx` runs that release and never whatever
|
|
@@ -142,6 +159,13 @@ import * as loot from '@xgamesfun/solana/client';
|
|
|
142
159
|
const ix = loot.openBoxIx({ player, project, box, nonce }); // one builder per instruction, no Solana SDK
|
|
143
160
|
```
|
|
144
161
|
|
|
162
|
+
Players with no SOL can still play when the project sponsors their fees: `solanaApp({ sponsor })` has a key on the
|
|
163
|
+
project's own server pay each step's fees and rent (never its price), and `@xgamesfun/solana/sponsor` is that server's
|
|
164
|
+
side: `sponsorSigner` dry-runs and checks every transaction before it signs, under the project's own per-player budget.
|
|
165
|
+
|
|
166
|
+
Building on `@solana/kit`? `loot.toKitInstruction(ix)` gives any builder's instruction in Kit's shape
|
|
167
|
+
(`{ programAddress, accounts, data }`), and `loot.fromKitInstruction` turns one back.
|
|
168
|
+
|
|
145
169
|
| Import | What |
|
|
146
170
|
| -------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
147
171
|
| `@xgamesfun/solana` | the economy tools: `check`, `simulate`, `plan`, `cost`, `explainEconomy`, `review`, `model` |
|
|
@@ -155,10 +179,35 @@ const ix = loot.openBoxIx({ player, project, box, nonce }); // one builder per i
|
|
|
155
179
|
The client, chain and play modules run in browsers and Node 22 or later, with no bundler required. The package
|
|
156
180
|
ships TypeScript declarations for every entry point.
|
|
157
181
|
|
|
182
|
+
## Questions people ask
|
|
183
|
+
|
|
184
|
+
**How do I make a loot box, gacha or pack opening on Solana without writing a smart contract?** Describe it as a box
|
|
185
|
+
in a spec (its price, its outcomes and their odds, what each one pays), run `xsolana check` and `xsolana simulate`, then
|
|
186
|
+
approve it in your wallet with `xsolana approve`. Rolls come from MagicBlock's VRF, and a buyer is refunded if the
|
|
187
|
+
oracle never answers. [`examples/`](examples) has whole worked economies.
|
|
188
|
+
|
|
189
|
+
**How do I add staking to an NFT collection, even one I didn't mint?** A staking farm takes any collection, with lock
|
|
190
|
+
tiers, boosts and fixed or shared reward rates. The project needs no authority over the NFTs: see
|
|
191
|
+
[`docs/nfts.md`](docs/nfts.md).
|
|
192
|
+
|
|
193
|
+
**Can I run a lottery, raffle or auction on Solana without deploying a program?** Yes: lotteries (including no-loss
|
|
194
|
+
ones), raffles with a Merkle allowlist, and auctions that refund the outbid at once are all routes in the same spec.
|
|
195
|
+
|
|
196
|
+
**Is there an MCP server or Claude Code plugin for building on Solana?** Yes: the `xsolana` plugin for Claude Code and
|
|
197
|
+
Codex, the hosted server at `https://xgames.fun/mcp` for chat apps, and `npx -y --package=@xgamesfun/solana@0.25.3 xsolana mcp` for any MCP client. It is listed in the MCP
|
|
198
|
+
Registry as `io.github.XGamesFun/solana-kit`.
|
|
199
|
+
|
|
200
|
+
**How is this different from writing my own Anchor program?** Nothing is written, deployed or upgraded per project:
|
|
201
|
+
every project's rules are checked by the one program, and its tests run every instruction against that program in
|
|
202
|
+
LiteSVM. The trade-off is that an economy can only be what the spec can say ([known limits](docs/program.md#known-limits)).
|
|
203
|
+
|
|
204
|
+
**Does it run on mainnet?** Not yet: devnet only until the program is independently audited.
|
|
205
|
+
|
|
158
206
|
## Documentation
|
|
159
207
|
|
|
160
208
|
| | |
|
|
161
209
|
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
210
|
+
| [`docs/tutorial-loot-box.md`](docs/tutorial-loot-box.md) | a loot box from one sentence to going live, step by step |
|
|
162
211
|
| [`docs/program.md`](docs/program.md) | the program: who signs, accounts, every instruction, fees, limits, receipts, costs |
|
|
163
212
|
| [`docs/nfts.md`](docs/nfts.md) | NFTs and tokens, with and without the project's authority |
|
|
164
213
|
| [`docs/harnesses.md`](docs/harnesses.md) | how the plugin reaches Claude Code and Codex, and the release checklist |
|
package/bin/xsolana.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the same answer, for an agent (or a person) without MCP.
|
|
4
4
|
//
|
|
5
5
|
// xsolana mcp serve the tools over stdio (what a plugin's MCP config runs)
|
|
6
|
+
// xsolana mcp --http [--port n] [--host h] serve the hosted tools over HTTP at /mcp (127.0.0.1:8787 by default)
|
|
6
7
|
// xsolana guide [part] [--json] what an economy can be made of, and what loot cannot do
|
|
7
8
|
// xsolana examples [words…] [--json] the nearest worked examples and rated economies
|
|
8
9
|
// xsolana check|simulate|cost|explain|review|publish|status [file] [--json] one tool on a spec file (default xsolana.json)
|
|
@@ -31,6 +32,7 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
|
|
|
31
32
|
xsolana status [file] a live economy on chain
|
|
32
33
|
xsolana connect add the xsolana tools to Claude Code or Codex (--agent claude|codex, --scope user|project, --local, --remove, --dry-run)
|
|
33
34
|
xsolana mcp serve the tools over MCP (stdio)
|
|
35
|
+
xsolana mcp --http serve the hosted tools over HTTP at http://127.0.0.1:8787/mcp (--port n, --host h)
|
|
34
36
|
xsolana tool --list every tool; xsolana tool <name> '<json>' runs one (--args-file f)
|
|
35
37
|
xsolana version this package's xsolana version and program
|
|
36
38
|
|
|
@@ -41,11 +43,11 @@ const USAGE = `xsolana: the tools for loot, one shared Solana program for game a
|
|
|
41
43
|
class UsageError extends Error {}
|
|
42
44
|
|
|
43
45
|
/** The flags that take a value: `--name value` or `--name=value`. */
|
|
44
|
-
const VALUED = new Set(['--args-file', '--agent', '--scope', '--port']);
|
|
46
|
+
const VALUED = new Set(['--args-file', '--agent', '--scope', '--port', '--host']);
|
|
45
47
|
/** The flags each command takes; any other is refused. */
|
|
46
48
|
const TOOL_FLAGS = ['--json'];
|
|
47
49
|
const FLAGS = {
|
|
48
|
-
mcp: [],
|
|
50
|
+
mcp: ['--http', '--port', '--host'],
|
|
49
51
|
guide: TOOL_FLAGS,
|
|
50
52
|
examples: TOOL_FLAGS,
|
|
51
53
|
check: TOOL_FLAGS,
|
|
@@ -119,6 +121,12 @@ async function main(argv) {
|
|
|
119
121
|
|
|
120
122
|
switch (command) {
|
|
121
123
|
case 'mcp': {
|
|
124
|
+
if (flags.has('--http')) {
|
|
125
|
+
const { serveHttp } = await import('../js/mcp/http.mjs');
|
|
126
|
+
await serveHttp({ port: portOf(values.get('--port')) ?? 8787, host: values.get('--host') ?? '127.0.0.1' });
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (flags.has('--port') || flags.has('--host')) throw new UsageError('--port and --host go with --http.');
|
|
122
130
|
const { serveStdio } = await import('../js/mcp/server.mjs');
|
|
123
131
|
await serveStdio();
|
|
124
132
|
break;
|
package/js/client/index.js
CHANGED
package/js/client/kit.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Instructions in @solana/kit's shape. The builders here return { programId, keys, data } (the shape of
|
|
2
|
+
// @solana/web3.js 1.x, which js/chain/tx.js compiles); an app built on @solana/kit takes { programAddress, accounts,
|
|
3
|
+
// data } instead, with each account's role as a number. These convert one to the other, with no dependency on Kit.
|
|
4
|
+
|
|
5
|
+
/** @solana/kit's AccountRole: bit 1 is a signer, bit 0 is writable. */
|
|
6
|
+
export const KIT_ACCOUNT_ROLE = Object.freeze({ READONLY: 0, WRITABLE: 1, READONLY_SIGNER: 2, WRITABLE_SIGNER: 3 });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One instruction from a builder here, as @solana/kit takes it: `appendTransactionMessageInstruction(toKitInstruction(ix), m)`.
|
|
10
|
+
* @param {{ programId: string, keys: { pubkey: string, isSigner: boolean, isWritable: boolean }[], data: Uint8Array }} ix
|
|
11
|
+
* @returns {{ programAddress: string, accounts: { address: string, role: number }[], data: Uint8Array }}
|
|
12
|
+
*/
|
|
13
|
+
export function toKitInstruction(ix) {
|
|
14
|
+
return {
|
|
15
|
+
programAddress: ix.programId,
|
|
16
|
+
accounts: ix.keys.map(k => ({ address: k.pubkey, role: (k.isSigner ? 2 : 0) | (k.isWritable ? 1 : 0) })),
|
|
17
|
+
data: ix.data,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A @solana/kit instruction in the shape this package's transactions take (js/chain/tx.js, js/play).
|
|
23
|
+
* @param {{ programAddress: string, accounts?: { address: string, role: number }[], data?: Uint8Array }} ix
|
|
24
|
+
* @returns {{ programId: string, keys: { pubkey: string, isSigner: boolean, isWritable: boolean }[], data: Uint8Array }}
|
|
25
|
+
*/
|
|
26
|
+
export function fromKitInstruction(ix) {
|
|
27
|
+
return {
|
|
28
|
+
programId: ix.programAddress,
|
|
29
|
+
keys: (ix.accounts ?? []).map(a => ({
|
|
30
|
+
pubkey: a.address,
|
|
31
|
+
isSigner: (a.role & 2) !== 0,
|
|
32
|
+
isWritable: (a.role & 1) !== 0,
|
|
33
|
+
})),
|
|
34
|
+
data: ix.data ?? new Uint8Array(),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/js/mcp/http.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The xsolana MCP server over HTTP (MCP's Streamable HTTP transport), for a hosted endpoint that chat apps add by URL
|
|
2
|
+
// (claude.ai, ChatGPT, Claude Desktop), where a local stdio server can't run. It speaks Request and Response, so a
|
|
3
|
+
// Next.js route handler, a Worker or `xsolana mcp --http` all serve it the same way.
|
|
4
|
+
//
|
|
5
|
+
// It is the stdio server's own handler (server.mjs `handle`) in remote mode: only the tools in REMOTE_TOOLS, every spec
|
|
6
|
+
// passed whole as an argument, no file read or written, nothing signed. Each request stands alone (no session), and
|
|
7
|
+
// every answer is one JSON body: no event stream, so GET is refused as the transport allows.
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { handle } from './server.mjs';
|
|
10
|
+
|
|
11
|
+
/** The largest request body read: a spec is a few kilobytes; a megabyte is far more than any. */
|
|
12
|
+
export const MAX_BODY_BYTES = 1_000_000;
|
|
13
|
+
|
|
14
|
+
const CORS = {
|
|
15
|
+
'access-control-allow-origin': '*',
|
|
16
|
+
'access-control-allow-methods': 'POST, OPTIONS',
|
|
17
|
+
'access-control-allow-headers': 'content-type, accept, authorization, mcp-protocol-version, mcp-session-id',
|
|
18
|
+
'access-control-max-age': '86400',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const json = (body, status = 200) =>
|
|
22
|
+
new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...CORS },
|
|
25
|
+
});
|
|
26
|
+
const empty = (status, headers = {}) => new Response(null, { status, headers: { ...CORS, ...headers } });
|
|
27
|
+
const rpcError = (code, message, status = 400) => json({ jsonrpc: '2.0', id: null, error: { code, message } }, status);
|
|
28
|
+
|
|
29
|
+
/** The body as text, or null once it is longer than `limit` bytes. */
|
|
30
|
+
async function bodyText(request, limit) {
|
|
31
|
+
const said = Number(request.headers.get('content-length'));
|
|
32
|
+
if (said > limit) return null;
|
|
33
|
+
if (!request.body) return '';
|
|
34
|
+
const reader = request.body.getReader();
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done) break;
|
|
40
|
+
size += value.length;
|
|
41
|
+
if (size > limit) {
|
|
42
|
+
await reader.cancel();
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
chunks.push(value);
|
|
46
|
+
}
|
|
47
|
+
const all = new Uint8Array(size);
|
|
48
|
+
let at = 0;
|
|
49
|
+
for (const c of chunks) {
|
|
50
|
+
all.set(c, at);
|
|
51
|
+
at += c.length;
|
|
52
|
+
}
|
|
53
|
+
return new TextDecoder().decode(all);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One HTTP request to the MCP endpoint, answered. `allowOrigin(origin)` decides which browser origins may call it (a
|
|
58
|
+
* request with no Origin header, from a server or a desktop app, always may); by default every origin, since the
|
|
59
|
+
* endpoint holds no session, no cookie and nothing private.
|
|
60
|
+
* @param {Request} request
|
|
61
|
+
* @param {{ allowOrigin?: (origin: string) => boolean, maxBodyBytes?: number, ctx?: object }} [options]
|
|
62
|
+
* @returns {Promise<Response>}
|
|
63
|
+
*/
|
|
64
|
+
export async function mcpHttp(request, { allowOrigin = () => true, maxBodyBytes = MAX_BODY_BYTES, ctx = {} } = {}) {
|
|
65
|
+
const origin = request.headers.get('origin');
|
|
66
|
+
if (origin && !allowOrigin(origin)) return rpcError(-32000, 'This origin may not call this server.', 403);
|
|
67
|
+
if (request.method === 'OPTIONS') return empty(204);
|
|
68
|
+
if (request.method !== 'POST') return empty(405, { allow: 'POST, OPTIONS' });
|
|
69
|
+
const text = await bodyText(request, maxBodyBytes);
|
|
70
|
+
if (text === null) return rpcError(-32600, `The request is larger than ${maxBodyBytes} bytes.`, 413);
|
|
71
|
+
let message;
|
|
72
|
+
try {
|
|
73
|
+
message = JSON.parse(text);
|
|
74
|
+
} catch {
|
|
75
|
+
return rpcError(-32700, 'Not JSON.');
|
|
76
|
+
}
|
|
77
|
+
const answer = m =>
|
|
78
|
+
handle(m, { ...ctx, remote: true }).catch(e => ({
|
|
79
|
+
jsonrpc: '2.0',
|
|
80
|
+
id: m?.id ?? null,
|
|
81
|
+
error: { code: -32603, message: e.message },
|
|
82
|
+
}));
|
|
83
|
+
if (Array.isArray(message)) {
|
|
84
|
+
const replies = (await Promise.all(message.map(answer))).filter(Boolean);
|
|
85
|
+
return replies.length ? json(replies) : empty(202);
|
|
86
|
+
}
|
|
87
|
+
const reply = await answer(message);
|
|
88
|
+
// A notification or a client's response gets no body.
|
|
89
|
+
return reply ? json(reply) : empty(202);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serves mcpHttp at `path` on this machine (`xsolana mcp --http`). It listens on 127.0.0.1 unless told otherwise, and
|
|
94
|
+
* then answers only pages on localhost: a site in the browser can't reach it by renaming its host (DNS rebinding).
|
|
95
|
+
* @returns {Promise<{ address(): { port: number } | string | null, close(done?: () => void): unknown }>} the node:http server
|
|
96
|
+
*/
|
|
97
|
+
export function serveHttp({ port = 8787, host = '127.0.0.1', path = '/mcp' } = {}) {
|
|
98
|
+
const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
99
|
+
const allowOrigin = origin => {
|
|
100
|
+
if (!local) return true;
|
|
101
|
+
try {
|
|
102
|
+
return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(origin).hostname);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const server = createServer(async (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
110
|
+
if (url.pathname !== path) {
|
|
111
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end(`The MCP endpoint is ${path}.`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const request = new Request(url, {
|
|
115
|
+
method: req.method,
|
|
116
|
+
headers: Object.entries(req.headers).flatMap(([k, v]) =>
|
|
117
|
+
v === undefined ? [] : (Array.isArray(v) ? v : [v]).map(one => [k, one]),
|
|
118
|
+
),
|
|
119
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req,
|
|
120
|
+
duplex: 'half',
|
|
121
|
+
});
|
|
122
|
+
const response = await mcpHttp(request, { allowOrigin });
|
|
123
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
124
|
+
res.end(response.body ? Buffer.from(await response.arrayBuffer()) : undefined);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
res.writeHead(500, { 'content-type': 'text/plain' }).end(e.message);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
server.once('error', reject);
|
|
131
|
+
server.listen(port, host, () => {
|
|
132
|
+
console.error(`xsolana MCP server over HTTP: http://${host}:${server.address().port}${path}`);
|
|
133
|
+
resolve(server);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
package/js/mcp/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// stdout is the protocol: everything else goes to stderr.
|
|
8
8
|
import { validate } from '../economy/validate.mjs';
|
|
9
|
-
import { PROGRAM_INFO, tools, VERSION } from './tools.mjs';
|
|
9
|
+
import { PACKAGE_VERSION, PROGRAM_INFO, REMOTE_TOOLS, tools, VERSION } from './tools.mjs';
|
|
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.',
|
|
@@ -14,6 +14,27 @@ export const INSTRUCTIONS = [
|
|
|
14
14
|
`Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
|
|
15
15
|
].join(' ');
|
|
16
16
|
|
|
17
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
export const REMOTE_INSTRUCTIONS = [
|
|
22
|
+
INSTRUCTIONS.split(' Start with')[0],
|
|
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.)`,
|
|
25
|
+
`Program ${PROGRAM_INFO.program} on ${PROGRAM_INFO.cluster} (loot ${VERSION}). Devnet only until audited.`,
|
|
26
|
+
].join(' ');
|
|
27
|
+
|
|
28
|
+
/** A tool as tools/list shows it; on a hosted server, without the `file` argument it can't take. */
|
|
29
|
+
function listed(t, remote) {
|
|
30
|
+
let inputSchema = t.inputSchema;
|
|
31
|
+
if (remote && inputSchema.properties?.file) {
|
|
32
|
+
const { file: _file, ...properties } = inputSchema.properties;
|
|
33
|
+
inputSchema = { ...inputSchema, properties };
|
|
34
|
+
}
|
|
35
|
+
return { name: t.name, description: t.description, inputSchema, annotations: t.annotations };
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
39
|
* The protocol versions this server speaks, newest first. A client that asks for one of them gets it; any other ask
|
|
19
40
|
* gets the newest, and the client decides whether it can go on (the spec's version negotiation).
|
|
@@ -36,6 +57,13 @@ export async function runTool(name, input = {}, ctx = {}) {
|
|
|
36
57
|
text: `No tool ${name}.${near.length ? ` Did you mean ${near.join(', ')}?` : ''} Tools: ${tools.map(t => t.name).join(', ')}.`,
|
|
37
58
|
};
|
|
38
59
|
}
|
|
60
|
+
if (ctx.remote && !REMOTE_TOOLS.has(name))
|
|
61
|
+
return {
|
|
62
|
+
isError: true,
|
|
63
|
+
text: `${name} works on the person’s own files, so it runs only in the local server (npx -y --package=@xgamesfun/solana@${PACKAGE_VERSION} xsolana mcp).`,
|
|
64
|
+
};
|
|
65
|
+
if (ctx.remote && 'file' in input)
|
|
66
|
+
return { isError: true, text: 'This hosted server reads no files: pass the whole spec as "spec".' };
|
|
39
67
|
const bad = inputProblems(tool.inputSchema, input);
|
|
40
68
|
if (bad.length) return { isError: true, text: bad.join('; ') };
|
|
41
69
|
try {
|
|
@@ -64,22 +92,18 @@ export async function handle(message, ctx = {}) {
|
|
|
64
92
|
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
65
93
|
capabilities: { tools: {} },
|
|
66
94
|
serverInfo: { name: 'xsolana', version: VERSION },
|
|
67
|
-
instructions: INSTRUCTIONS,
|
|
95
|
+
instructions: ctx.remote ? REMOTE_INSTRUCTIONS : INSTRUCTIONS,
|
|
68
96
|
});
|
|
69
97
|
}
|
|
70
98
|
case 'ping':
|
|
71
99
|
return reply({});
|
|
72
100
|
case 'tools/list':
|
|
73
101
|
return reply({
|
|
74
|
-
tools: tools.map(t => (
|
|
75
|
-
name: t.name,
|
|
76
|
-
description: t.description,
|
|
77
|
-
inputSchema: t.inputSchema,
|
|
78
|
-
annotations: t.annotations,
|
|
79
|
-
})),
|
|
102
|
+
tools: tools.filter(t => !ctx.remote || REMOTE_TOOLS.has(t.name)).map(t => listed(t, ctx.remote)),
|
|
80
103
|
});
|
|
81
104
|
case 'tools/call': {
|
|
82
|
-
const
|
|
105
|
+
const input = params?.arguments ?? {};
|
|
106
|
+
const result = await runTool(params?.name, input, ctx.remote ? ctx : { ...ctx, cwd: ctx.cwd ?? process.cwd() });
|
|
83
107
|
// A tool that failed says so (isError), and keeps whatever it found (a save's refusals, say) as structuredContent.
|
|
84
108
|
const { text, isError, ...data } = jsonable(result);
|
|
85
109
|
return reply({
|
package/js/mcp/tools.mjs
CHANGED
|
@@ -69,6 +69,22 @@ const nowOf = ctx => ctx?.now ?? Math.floor(Date.now() / 1000);
|
|
|
69
69
|
const cwdOf = ctx => ctx?.cwd ?? process.cwd();
|
|
70
70
|
export const DEFAULT_FILE = 'xsolana.json';
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The tools a hosted server (js/mcp/http.mjs) offers: those that read nothing but their arguments and the chain.
|
|
74
|
+
* xsolana_save and xsolana_publish write and read the person's own files, so they run only on the person's machine.
|
|
75
|
+
*/
|
|
76
|
+
export const REMOTE_TOOLS = new Set([
|
|
77
|
+
'xsolana_guide',
|
|
78
|
+
'xsolana_examples',
|
|
79
|
+
'xsolana_check',
|
|
80
|
+
'xsolana_simulate',
|
|
81
|
+
'xsolana_cost',
|
|
82
|
+
'xsolana_explain',
|
|
83
|
+
'xsolana_review',
|
|
84
|
+
'xsolana_status',
|
|
85
|
+
'xsolana_client',
|
|
86
|
+
]);
|
|
87
|
+
|
|
72
88
|
/** The real path of `p`, or of the nearest folder above it that exists, with the rest added back. */
|
|
73
89
|
function realPath(p) {
|
|
74
90
|
try {
|
|
@@ -141,6 +157,11 @@ const SOURCE = {
|
|
|
141
157
|
async function load(input, ctx) {
|
|
142
158
|
if (input.spec) return { economy: economyOf(input.spec), from: 'the spec given' };
|
|
143
159
|
if (input.example) return { economy: example(input.example).spec, from: `the example ${input.example}` };
|
|
160
|
+
// A hosted server (js/mcp/http.mjs) reads no file: the machine it runs on is not the person's.
|
|
161
|
+
if (ctx?.remote)
|
|
162
|
+
throw new Error(
|
|
163
|
+
'This hosted server reads no files: pass the whole spec as "spec" (or a worked example by name as "example").',
|
|
164
|
+
);
|
|
144
165
|
const rel = input.file ?? DEFAULT_FILE;
|
|
145
166
|
const file = projectFile(rel, ctx);
|
|
146
167
|
if (!existsSync(file)) {
|
|
@@ -591,7 +612,7 @@ export const tools = [
|
|
|
591
612
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
592
613
|
description:
|
|
593
614
|
'A live economy as it is on chain: the project, every pool’s balance, every record the spec names (found or missing). Reads devnet (or XSOLANA_RPC_URL).',
|
|
594
|
-
inputSchema: { type: 'object', properties: { file: SOURCE.file }, additionalProperties: false },
|
|
615
|
+
inputSchema: { type: 'object', properties: { spec: SOURCE.spec, file: SOURCE.file }, additionalProperties: false },
|
|
595
616
|
async handler(input, ctx) {
|
|
596
617
|
const { economy, from } = await load(input, ctx);
|
|
597
618
|
if (!economy.live?.project)
|
|
@@ -683,6 +704,7 @@ export const tools = [
|
|
|
683
704
|
' `chosenRecipient` (buy, openBox, pay): the wallet (or collectible) the player names for a "chosen" share of the split (a referrer from the link)',
|
|
684
705
|
' app.lotteries(), app.buyTickets(key, n, { listing }) (or { maxPrice }: a ticket’s most, in base units),',
|
|
685
706
|
' app.stake(key, amount), app.harvest(key), app.staked(key) (each seat and what it is owed now), app.recipes(), app.craft(key, assets, 1, { listing }), app.claimGrant(key),',
|
|
707
|
+
' solanaApp({ ..., sponsor: httpSponsor({ url, address }) }) the project pays players’ fees (never the price) from a key on its own server: @xgamesfun/solana/sponsor sponsorSigner + sponsorHandler there',
|
|
686
708
|
' app.collectibles(), app.balances() what a player does. `listing`: the entry shop(), boxes(), lotteries() or recipes() showed the',
|
|
687
709
|
' player; its price (or version) is the most they pay: one that moved since is refused, never paid unseen',
|
|
688
710
|
' a prize (a grant, a lottery place, a contest win, a mission, a recipe) is given in the instruction that wins it,',
|
|
@@ -704,6 +726,7 @@ export const tools = [
|
|
|
704
726
|
' import * as loot from "@xgamesfun/solana/client";',
|
|
705
727
|
' loot.pda.project(owner, number), loot.pda.box(project, id), … every address the program derives',
|
|
706
728
|
' loot.openBoxIx({ player, project, box, nonce }), loot.buyIx({ player, project, box, items }) … one builder per instruction; each returns { programId, keys, data }',
|
|
729
|
+
' loot.toKitInstruction(ix) a builder’s instruction in @solana/kit’s shape ({ programAddress, accounts, data }), for an app built on Kit; loot.fromKitInstruction back',
|
|
707
730
|
' loot.payNftIx({ player, project, asset, to }) pay with an NFT (burned, or given to `to`): the app admits by the receipt',
|
|
708
731
|
' loot.openListingIx / takeListingIx / closeListingIx, loot.listingTerms(listing), loot.pda.listing(project, asset), loot.renterOf(listing, now)',
|
|
709
732
|
' a take or a bid signs the record’s terms: loot.listingTerms / offerTerms / auctionTerms(record), the hash of its bytes as read, but what a take changes',
|
package/js/play/index.js
CHANGED
|
@@ -57,6 +57,20 @@ import {
|
|
|
57
57
|
import { compileMessage, readTransaction, wireTransaction } from '../chain/tx.js';
|
|
58
58
|
import * as loot from '../client/index.js';
|
|
59
59
|
import { browserStore, keyring } from './keys.js';
|
|
60
|
+
import { SPONSOR_MAX_LAMPORTS, sponsoredCost, sponsorGiveBack, sponsorTopUp, stepShape, topUpFor } from './sponsor.js';
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
checkSponsored,
|
|
64
|
+
httpSponsor,
|
|
65
|
+
sponsoredCost,
|
|
66
|
+
sponsorGiveBack,
|
|
67
|
+
sponsorHandler,
|
|
68
|
+
sponsorSigner,
|
|
69
|
+
sponsorTopUp,
|
|
70
|
+
stepShape,
|
|
71
|
+
topUpFor,
|
|
72
|
+
SPONSOR_MAX_LAMPORTS,
|
|
73
|
+
} from './sponsor.js';
|
|
60
74
|
|
|
61
75
|
// The options of a call made without any: read-only, and typed so the generated declarations take any option.
|
|
62
76
|
/** A lottery's steps in `claims()`: each depends on the one before, so `claimAll` takes one of a lottery's a call. */
|
|
@@ -190,6 +204,7 @@ export function solanaApp({
|
|
|
190
204
|
programs = {},
|
|
191
205
|
now = () => Math.floor(Date.now() / 1000),
|
|
192
206
|
keyStore = browserStore(),
|
|
207
|
+
sponsor = null,
|
|
193
208
|
}) {
|
|
194
209
|
const live = spec?.live;
|
|
195
210
|
if (!live?.project) throw new Error('This economy is not live yet: its xsolana.json has no "live" section.');
|
|
@@ -591,6 +606,15 @@ export function solanaApp({
|
|
|
591
606
|
// A compressed NFT's record is closed in the transaction that used it, its prover signing: its rent comes back.
|
|
592
607
|
instructions = loot.withLeafCloses(instructions);
|
|
593
608
|
if (!keepWrapped) instructions = await unwrapAfter(instructions);
|
|
609
|
+
if (sponsor) {
|
|
610
|
+
const sponsored = await sendSponsored(instructions, extra);
|
|
611
|
+
if (sponsored) return sponsored;
|
|
612
|
+
}
|
|
613
|
+
return sendUnsponsored(instructions, extra);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The player's step as they pay for it themselves: their wallet the fee payer (`sendFitting`). */
|
|
617
|
+
async function sendUnsponsored(instructions, extra = []) {
|
|
594
618
|
const sign = async message => {
|
|
595
619
|
const { signers } = readTransaction(wireTransaction(message));
|
|
596
620
|
const sigs = await Promise.all(
|
|
@@ -616,6 +640,79 @@ export function solanaApp({
|
|
|
616
640
|
|
|
617
641
|
const sendNow = (...args) => send(...args);
|
|
618
642
|
|
|
643
|
+
/** The most the sponsor pays a transaction (its top-up and network fee), as the app was told. */
|
|
644
|
+
// It lends the player that, less room for the network fee, and takes back what the step didn't need.
|
|
645
|
+
const sponsorMax = sponsor ? BigInt(sponsor.maxLamports ?? SPONSOR_MAX_LAMPORTS) - SIGNATURE_FEE * 4n : 0n;
|
|
646
|
+
|
|
647
|
+
/** Every account a list of instructions names, its programs too. */
|
|
648
|
+
const namedIn = instructions => [
|
|
649
|
+
...new Set(instructions.flatMap(ix => [ix.programId, ...ix.keys.map(k => k.pubkey)])),
|
|
650
|
+
];
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The player's step with the sponsor paying (`sponsor`, js/play/sponsor.js): the sponsor the fee payer and the
|
|
654
|
+
* player topped up first by what the step costs them in fees and rent (a dry run, as the sponsor's server will
|
|
655
|
+
* check it), the player's wallet signing, then the sponsor's server. Null when it can't be sponsored as one
|
|
656
|
+
* transaction (it needs a lookup table of its own, or proofs sent first), or the sponsor refused it and the player
|
|
657
|
+
* pays instead (`sponsor.playerPaysIfRefused`, the default): the step then goes as the player's own.
|
|
658
|
+
*/
|
|
659
|
+
async function sendSponsored(instructions, extra) {
|
|
660
|
+
const me = wallet.address;
|
|
661
|
+
const tables = await standing();
|
|
662
|
+
const { blockhash, lastValidBlockHeight } = await client.latestBlockhash();
|
|
663
|
+
// Lent the most, the step is tried; then sent lent the same, the player giving back last all but what it costs in
|
|
664
|
+
// fees and rent: a step can need more SOL midway than it costs (a wrapped-SOL account it opens and closes).
|
|
665
|
+
const compile = (back = 0n) =>
|
|
666
|
+
compileFitting({
|
|
667
|
+
payer: sponsor.address,
|
|
668
|
+
blockhash,
|
|
669
|
+
instructions: [
|
|
670
|
+
sponsorTopUp(sponsor.address, me, sponsorMax),
|
|
671
|
+
...instructions,
|
|
672
|
+
...(back > 0n ? [sponsorGiveBack(sponsor.address, me, back)] : []),
|
|
673
|
+
],
|
|
674
|
+
tables,
|
|
675
|
+
});
|
|
676
|
+
const tried = compile();
|
|
677
|
+
if (!tried) return null;
|
|
678
|
+
const addresses = namedIn([sponsorTopUp(sponsor.address, me, 0n), ...instructions]);
|
|
679
|
+
const pre = await client.accounts(addresses);
|
|
680
|
+
// A step loot refuses is refused in words; one that fails otherwise (the sponsor short of SOL) goes as the player's.
|
|
681
|
+
const run = await client
|
|
682
|
+
.simulate(wireTransaction(tried), { accounts: addresses, replaceBlockhash: true })
|
|
683
|
+
.catch(e => (loot.lootError(e?.logs ?? []) ? inWords(e) : null));
|
|
684
|
+
if (!run) return null;
|
|
685
|
+
const post = run.accounts;
|
|
686
|
+
const at = a => addresses.indexOf(a);
|
|
687
|
+
const balance = a => (a ? BigInt(a.lamports) : 0n);
|
|
688
|
+
const cost = sponsoredCost({
|
|
689
|
+
addresses,
|
|
690
|
+
pre,
|
|
691
|
+
post,
|
|
692
|
+
project,
|
|
693
|
+
skip: [sponsor.address, me],
|
|
694
|
+
...stepShape(instructions),
|
|
695
|
+
});
|
|
696
|
+
const spend = balance(pre[at(me)]) + sponsorMax - balance(post[at(me)]);
|
|
697
|
+
const message = compile(sponsorMax - topUpFor({ spend, cost }));
|
|
698
|
+
if (!message) return null;
|
|
699
|
+
const { signers } = readTransaction(wireTransaction(message));
|
|
700
|
+
const sigs = await Promise.all(
|
|
701
|
+
signers.map(async s => extra.find(k => k.address === s)?.sign(message) ?? new Uint8Array(64)),
|
|
702
|
+
);
|
|
703
|
+
const signed = await wallet.signTransaction(wireTransaction(message, sigs));
|
|
704
|
+
let wire;
|
|
705
|
+
try {
|
|
706
|
+
wire = await sponsor.sign(signed);
|
|
707
|
+
} catch (e) {
|
|
708
|
+
if (sponsor.playerPaysIfRefused === false) throw e;
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const signature = await client.send(wire).catch(inWords);
|
|
712
|
+
await client.confirm?.(signature, { lastValidBlockHeight });
|
|
713
|
+
return signature;
|
|
714
|
+
}
|
|
715
|
+
|
|
619
716
|
/**
|
|
620
717
|
* Several independent steps of the player's, each one transaction (`lists`: their instructions), signed in one wallet
|
|
621
718
|
* prompt when the wallet signs several at once (`wallet.signTransactions`), then sent in order; one by one otherwise,
|
|
@@ -630,7 +727,7 @@ export function solanaApp({
|
|
|
630
727
|
let signed = null;
|
|
631
728
|
let messages = [];
|
|
632
729
|
let lastValidBlockHeight = null;
|
|
633
|
-
if (wallet?.signTransactions && lists.length > 1) {
|
|
730
|
+
if (wallet?.signTransactions && lists.length > 1 && !sponsor) {
|
|
634
731
|
const latest = await client.latestBlockhash();
|
|
635
732
|
lastValidBlockHeight = latest.lastValidBlockHeight;
|
|
636
733
|
const tables = await standing();
|
|
@@ -791,6 +888,12 @@ export function solanaApp({
|
|
|
791
888
|
* them), and the accounts `watch` as they would be after: { ok, accounts, error }, `error` loot's when it refused
|
|
792
889
|
* ({ code, name, msg }, `loot.lootError`). */
|
|
793
890
|
async function dryRun(payer, instructions, watch = []) {
|
|
891
|
+
// A sponsored player's step is tried as it will be sent: the sponsor pays, and tops the player up (here by the
|
|
892
|
+
// most it pays, so a player with no SOL is tried as one the sponsor covers).
|
|
893
|
+
if (sponsor && payer === wallet?.address) {
|
|
894
|
+
instructions = [sponsorTopUp(sponsor.address, payer, sponsorMax), ...instructions];
|
|
895
|
+
payer = sponsor.address;
|
|
896
|
+
}
|
|
794
897
|
const { blockhash } = await client.latestBlockhash();
|
|
795
898
|
const message = compileFitting({ payer, blockhash, instructions, tables: await standing() });
|
|
796
899
|
if (!message) return { ok: false, accounts: [], error: null };
|
|
@@ -848,6 +951,17 @@ export function solanaApp({
|
|
|
848
951
|
return order.status === 'Waiting' && order.player === wallet.address ? [[address, order]] : [];
|
|
849
952
|
});
|
|
850
953
|
if (!waiting.length) return { signature: await send(instructions), rolls: [] };
|
|
954
|
+
// Sponsored, a settle key isn't funded (a plain account the player could empty is never sponsored): the step asks
|
|
955
|
+
// for each roll, and the player's wallet settles it once it is answered (one more prompt, sponsored too).
|
|
956
|
+
if (sponsor) {
|
|
957
|
+
const asked = [...instructions];
|
|
958
|
+
for (const [orderAddress, order] of waiting) asked.push(...(await asksFor(wallet.address, orderAddress, order)));
|
|
959
|
+
const tried = await dryRun(wallet.address, asked);
|
|
960
|
+
const signature = await send(tried.ok ? asked : instructions);
|
|
961
|
+
const rolls = [];
|
|
962
|
+
if (tried.ok) for (const [orderAddress] of waiting) rolls.push(await collect(orderAddress, { opened: true }));
|
|
963
|
+
return { signature, rolls };
|
|
964
|
+
}
|
|
851
965
|
const asks = [];
|
|
852
966
|
const funds = [];
|
|
853
967
|
const burners = [];
|
|
@@ -1337,7 +1451,7 @@ export function solanaApp({
|
|
|
1337
1451
|
on = null,
|
|
1338
1452
|
maxPrice,
|
|
1339
1453
|
listing,
|
|
1340
|
-
oneSignature =
|
|
1454
|
+
oneSignature = !sponsor,
|
|
1341
1455
|
chosenRecipient = null,
|
|
1342
1456
|
listProof,
|
|
1343
1457
|
count = 1,
|