@xgamesfun/solana 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -51
- package/bin/xsolana.mjs +23 -8
- package/examples/blind-forge.json +2 -4
- package/examples/coin-flip-arena.json +2 -5
- package/examples/duel-arena.json +1 -4
- package/examples/fair-presale.json +1 -3
- package/examples/hero-trials.json +14 -2
- package/examples/holder-den.json +38 -0
- package/examples/holders-drop.json +105 -0
- package/examples/ideas.json +788 -788
- package/examples/match-predictions.json +24 -0
- package/examples/nft-raffle.json +1 -0
- package/examples/pack-league.json +108 -0
- package/examples/partner-vault.json +19 -3
- package/examples/sacrifice-raffle.json +2 -10
- package/examples/shelter-fair.json +1 -0
- package/examples/stakers-draw.json +3 -7
- package/examples/stride-sneakers.json +164 -0
- package/examples/trade-in-shop.json +2 -3
- package/js/approve/details.mjs +117 -48
- package/js/approve/ed25519.mjs +5 -1
- package/js/approve/live-check.mjs +83 -26
- package/js/approve/page.html +217 -78
- package/js/approve/page.js +43 -15
- package/js/approve/server.mjs +276 -74
- package/js/chain/base58.js +7 -1
- package/js/chain/borsh.js +15 -11
- package/js/chain/bytes.js +11 -8
- package/js/chain/core.js +21 -5
- package/js/chain/curve.js +1 -1
- package/js/chain/lookup.js +118 -36
- package/js/chain/mint.js +6 -2
- package/js/chain/pda.js +2 -2
- package/js/chain/programs.js +6 -6
- package/js/chain/rpc.js +40 -14
- package/js/chain/token.js +13 -2
- package/js/chain/token2022.js +55 -16
- package/js/chain/tx.js +32 -22
- package/js/client/accounts.js +270 -80
- package/js/client/compressed.js +4 -3
- package/js/client/grant.js +165 -40
- package/js/client/idl/loot.js +1 -1
- package/js/client/idl/loot.json +104 -16
- package/js/client/ix.js +2121 -457
- package/js/client/pda.js +20 -18
- package/js/client/policy.js +12 -5
- package/js/client/random.js +1 -1
- package/js/client/sizes.js +220 -65
- package/js/client/traits.js +14 -5
- package/js/connect/connect.mjs +59 -17
- package/js/economy/check.mjs +3873 -690
- package/js/economy/cost.mjs +380 -56
- package/js/economy/examples.mjs +176 -35
- package/js/economy/explain.mjs +461 -110
- package/js/economy/guide.mjs +282 -59
- package/js/economy/legal.mjs +12 -6
- package/js/economy/live.mjs +60 -19
- package/js/economy/model.mjs +320 -97
- package/js/economy/plan.mjs +1022 -203
- package/js/economy/schema.json +127 -369
- package/js/economy/secrets.mjs +17 -7
- package/js/economy/simulate.mjs +771 -178
- package/js/economy/txsize.mjs +15 -3
- package/js/economy/validate.mjs +61 -24
- package/js/mcp/server.mjs +42 -14
- package/js/mcp/tools.mjs +293 -80
- package/js/play/index.js +1960 -461
- package/js/play/keys.js +35 -9
- package/js/results/index.js +223 -30
- package/js/results/server.js +230 -0
- package/js/results/wire.js +64 -16
- package/package.json +6 -4
- package/release.json +5 -5
- package/skills/xsolana-economy/SKILL.md +30 -16
- package/skills/xsolana-economy/references/after-launch.md +30 -20
- package/skills/xsolana-economy/references/apps.md +47 -36
- package/skills/xsolana-economy/references/sentence-to-spec.md +148 -129
- package/skills/xsolana-economy/references/telling-the-person.md +9 -6
- package/types/chain/core.d.ts +1 -1
- package/types/chain/lookup.d.ts +1 -1
- package/types/client/accounts.d.ts +24 -3
- package/types/client/grant.d.ts +2 -2
- package/types/client/idl/loot.d.ts +22 -0
- package/types/client/ix.d.ts +54 -45
- package/types/client/pda.d.ts +3 -1
- package/types/economy/guide.d.mts +5 -0
- package/types/economy/model.d.mts +48 -3
- package/types/economy/plan.d.mts +44 -2
- package/types/play/index.d.ts +8 -19
- package/types/results/index.d.ts +7 -6
- package/types/results/server.d.ts +126 -0
- package/types/results/wire.d.ts +1 -1
package/js/approve/server.mjs
CHANGED
|
@@ -49,25 +49,27 @@ class ApproveError extends Error {
|
|
|
49
49
|
export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
|
|
50
50
|
const rpc = rpcClient(notMainnet(url));
|
|
51
51
|
let cluster = null;
|
|
52
|
-
const checked = () =>
|
|
53
|
-
cluster
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
const checked = () =>
|
|
53
|
+
(cluster ??= checkCluster(rpc).catch(e => {
|
|
54
|
+
cluster = null;
|
|
55
|
+
throw e;
|
|
56
|
+
}));
|
|
56
57
|
return {
|
|
57
58
|
url,
|
|
58
59
|
latestBlockhash: async () => {
|
|
59
60
|
await checked();
|
|
60
61
|
return rpc.latestBlockhash();
|
|
61
62
|
},
|
|
62
|
-
rent: async
|
|
63
|
-
send: async
|
|
63
|
+
rent: async bytes => BigInt(await rpc.call('getMinimumBalanceForRentExemption', [bytes])),
|
|
64
|
+
send: async wire => {
|
|
64
65
|
await checked();
|
|
65
66
|
return rpc.send(wire);
|
|
66
67
|
},
|
|
67
68
|
confirm: (signature, options) => rpc.confirm(signature, options),
|
|
68
|
-
account:
|
|
69
|
+
account: address => rpc.account(address),
|
|
69
70
|
/** A sent transaction's status from the whole history: { err, confirmationStatus }, or null when unseen. */
|
|
70
|
-
signatureStatus: async
|
|
71
|
+
signatureStatus: async signature =>
|
|
72
|
+
(await rpc.call('getSignatureStatuses', [[signature], { searchTransactionHistory: true }])).value[0],
|
|
71
73
|
blockHeight: () => rpc.blockHeight(),
|
|
72
74
|
};
|
|
73
75
|
}
|
|
@@ -79,7 +81,10 @@ export function devnetChain(url = process.env.XSOLANA_RPC_URL || DEVNET) {
|
|
|
79
81
|
*/
|
|
80
82
|
export async function writeWhole(file, text) {
|
|
81
83
|
const target = await realpath(file).catch(() => file);
|
|
82
|
-
const mode = await stat(target).then(
|
|
84
|
+
const mode = await stat(target).then(
|
|
85
|
+
s => s.mode & 0o777,
|
|
86
|
+
() => 0o644,
|
|
87
|
+
);
|
|
83
88
|
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(6).toString('hex')}.tmp`);
|
|
84
89
|
try {
|
|
85
90
|
const handle = await openFile(temp, 'wx', mode);
|
|
@@ -103,7 +108,10 @@ function walletAddress(wallet) {
|
|
|
103
108
|
try {
|
|
104
109
|
addressBytes(text);
|
|
105
110
|
} catch {
|
|
106
|
-
throw new ApproveError(
|
|
111
|
+
throw new ApproveError(
|
|
112
|
+
400,
|
|
113
|
+
`${text ? JSON.stringify(text.slice(0, 60)) : 'No wallet'}: that is not a Solana address. Connect a wallet and try again.`,
|
|
114
|
+
);
|
|
107
115
|
}
|
|
108
116
|
return text;
|
|
109
117
|
}
|
|
@@ -112,18 +120,25 @@ function walletAddress(wallet) {
|
|
|
112
120
|
* One approval session for the spec in `file`. `chain`: { latestBlockhash, rent(bytes), send(wire), confirm(sig) }.
|
|
113
121
|
* `write(file, text)` writes the spec file (`writeWhole`). Exposed for tests; `approve` serves it.
|
|
114
122
|
*/
|
|
115
|
-
export async function createApproval({
|
|
123
|
+
export async function createApproval({
|
|
124
|
+
file,
|
|
125
|
+
chain = devnetChain(),
|
|
126
|
+
now = () => Math.floor(Date.now() / 1000),
|
|
127
|
+
clock = Date.now,
|
|
128
|
+
write = writeWhole,
|
|
129
|
+
}) {
|
|
116
130
|
let spec = JSON.parse(await readFile(file, 'utf8'));
|
|
117
131
|
if (spec.economy) spec = spec.economy;
|
|
118
132
|
const verdict = check(spec, { now: now() });
|
|
119
|
-
if (!verdict.ok)
|
|
133
|
+
if (!verdict.ok)
|
|
134
|
+
throw new ApproveError(400, `${file} does not check:\n${describe({ refusals: verdict.refusals, warnings: [] })}`);
|
|
120
135
|
const keys = new Map();
|
|
121
136
|
let prepared = null;
|
|
122
137
|
let busy = false;
|
|
123
138
|
// One wallet read, prepare or complete at a time: each settles and writes the live section, and a batch built while
|
|
124
139
|
// another batch lands would send that batch's steps again.
|
|
125
140
|
let queue = Promise.resolve();
|
|
126
|
-
const serial =
|
|
141
|
+
const serial = fn => {
|
|
127
142
|
const run = queue.then(fn, fn);
|
|
128
143
|
queue = run.catch(() => {});
|
|
129
144
|
return run;
|
|
@@ -150,13 +165,19 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
150
165
|
await checkMints({ m: model(spec, { now: at }), chain });
|
|
151
166
|
} catch (e) {
|
|
152
167
|
if (!(e instanceof MintMismatch)) throw e;
|
|
153
|
-
throw new ApproveError(
|
|
168
|
+
throw new ApproveError(
|
|
169
|
+
409,
|
|
170
|
+
`${e.message} Nothing was sent: correct ${path.basename(file)}, then run \`xsolana approve\` again.`,
|
|
171
|
+
);
|
|
154
172
|
}
|
|
155
173
|
try {
|
|
156
174
|
return await checkLive({ live: live(), m: model(spec, { now: at }), wallet, chain });
|
|
157
175
|
} catch (e) {
|
|
158
176
|
if (!(e instanceof LiveMismatch)) throw e;
|
|
159
|
-
throw new ApproveError(
|
|
177
|
+
throw new ApproveError(
|
|
178
|
+
409,
|
|
179
|
+
`${e.message} Nothing was sent. ${path.basename(file)}’s live section is not what the chain says: restore the file \`xsolana approve\` wrote, or remove its live section to start a new setup.`,
|
|
180
|
+
);
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
|
@@ -166,15 +187,30 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
166
187
|
for (const name of p0.freshKeys) if (!keys.has(name)) keys.set(name, freshKeypair());
|
|
167
188
|
// The accounts the owner makes outside loot: a mint, and the project's tree of compressed NFTs.
|
|
168
189
|
const tree = compressedTree(model(spec, { now: at }));
|
|
169
|
-
const rents = new Map(
|
|
170
|
-
|
|
190
|
+
const rents = new Map(
|
|
191
|
+
await Promise.all(
|
|
192
|
+
[MINT_BYTES, ...(tree ? [tree.bytes] : [])].map(async bytes => [
|
|
193
|
+
bytes,
|
|
194
|
+
boundedRent(bytes, await chain.rent(bytes)),
|
|
195
|
+
]),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const rent = bytes => {
|
|
171
199
|
if (!rents.has(bytes)) throw new Error(`No rent known for ${bytes} bytes.`);
|
|
172
200
|
return rents.get(bytes);
|
|
173
201
|
};
|
|
174
202
|
const addresses = Object.fromEntries([...keys].map(([n, k]) => [n, k.address]));
|
|
175
203
|
// A recent slot, for the project's standing lookup table (made at go-live at a slot still in the slot hashes when it lands).
|
|
176
204
|
const hashes = slotHashes((await chain.account(SYSVAR_SLOT_HASHES).catch(() => null))?.data);
|
|
177
|
-
const p = prepare(spec, {
|
|
205
|
+
const p = prepare(spec, {
|
|
206
|
+
owner,
|
|
207
|
+
keys: addresses,
|
|
208
|
+
live: live() ?? {},
|
|
209
|
+
now: at,
|
|
210
|
+
rent,
|
|
211
|
+
only,
|
|
212
|
+
slot: hashes[0] ?? null,
|
|
213
|
+
});
|
|
178
214
|
if (!p.ok) throw new ApproveError(400, describe({ refusals: p.refusals, warnings: [] }));
|
|
179
215
|
return p;
|
|
180
216
|
}
|
|
@@ -183,7 +219,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
183
219
|
* What each step pays to whom and which keys it trusts, read back from its instructions, in full. A change says, for
|
|
184
220
|
* a record that is live already, how each line differs from what is on chain (`readLive`).
|
|
185
221
|
*/
|
|
186
|
-
const names = () =>
|
|
222
|
+
const names = () =>
|
|
223
|
+
namesOf(model(spec, { now: at }), {
|
|
224
|
+
treasury: madeSoFar(live())?.treasury ?? model(spec, { now: at }).treasuryWallet ?? null,
|
|
225
|
+
});
|
|
187
226
|
/**
|
|
188
227
|
* What is live, read from the chain once a wallet connects and the file's live section is checked: for each step,
|
|
189
228
|
* the lines of the records it writes as they are on chain now ([] for a record not there yet), and the treasury.
|
|
@@ -204,10 +243,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
204
243
|
// A grant published before and gone from the chain (closed) that the change makes again with a list: a list leaf
|
|
205
244
|
// names no grant or incarnation, so the same root would pay every listed round again.
|
|
206
245
|
const remade = [];
|
|
207
|
-
const published = new Set((spec.live.published.grants ?? []).map(
|
|
246
|
+
const published = new Set((spec.live.published.grants ?? []).map(g => g.key));
|
|
208
247
|
for (const g of model(spec, { now: at }).grants) {
|
|
209
248
|
if (!g.list?.length || !published.has(g.key) || !p.ctx.project) continue;
|
|
210
|
-
if (!(await chain.account(pda.grant(p.ctx.project, g.id)).catch(() => 'unread')))
|
|
249
|
+
if (!(await chain.account(pda.grant(p.ctx.project, g.id)).catch(() => 'unread')))
|
|
250
|
+
remade.push(`“${g.name ?? g.key}”`);
|
|
211
251
|
}
|
|
212
252
|
wasLive = { steps: was, treasury: p.ctx.treasury, remade };
|
|
213
253
|
}
|
|
@@ -217,13 +257,27 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
217
257
|
return was ? compareDetails(lines, was) : lines;
|
|
218
258
|
};
|
|
219
259
|
/** The lines a change is held against for its outside-address warning: what was read from the chain, or nothing yet. */
|
|
220
|
-
const wasLines = () =>
|
|
221
|
-
|
|
222
|
-
|
|
260
|
+
const wasLines = () =>
|
|
261
|
+
spec.live?.published
|
|
262
|
+
? [
|
|
263
|
+
...(wasLive ? [...wasLive.steps.values()].flat() : []),
|
|
264
|
+
...(wasLive?.treasury ? [`Treasury: ${wasLive.treasury}`] : []),
|
|
265
|
+
]
|
|
266
|
+
: null;
|
|
267
|
+
const remadeLists = () =>
|
|
268
|
+
wasLive?.remade?.length
|
|
269
|
+
? `This change makes ${wasLive.remade.join(', ')} again after it was closed, with the same list: a list names no grant, so every wallet on it could claim every listed round again. Give the grant a fresh list (new rounds) before you approve.`
|
|
270
|
+
: null;
|
|
271
|
+
const unread = () =>
|
|
272
|
+
spec.live?.published && !wasLive
|
|
273
|
+
? 'What is live has not been read from the chain yet: connect your wallet, and each line is marked against what is on chain.'
|
|
274
|
+
: null;
|
|
223
275
|
|
|
224
276
|
// The project's own settings a change names are never published: the page says so.
|
|
225
277
|
const changed = mode === 'change' ? changedParts(spec).settings : [];
|
|
226
|
-
const settings = changed.length
|
|
278
|
+
const settings = changed.length
|
|
279
|
+
? `This change names new project settings (${changed.join(', ')}) that publishing never writes: on chain they stay as they are.`
|
|
280
|
+
: null;
|
|
227
281
|
|
|
228
282
|
const legal = legalFlags(verdict);
|
|
229
283
|
|
|
@@ -231,7 +285,17 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
231
285
|
const said = new Map();
|
|
232
286
|
function view(p, n = names(), extra = {}) {
|
|
233
287
|
const done = new Set(live()?.landed ?? []);
|
|
234
|
-
const steps = p.ok
|
|
288
|
+
const steps = p.ok
|
|
289
|
+
? p.transactions.flatMap(t =>
|
|
290
|
+
t.steps.map((s, i) => ({
|
|
291
|
+
id: s.id,
|
|
292
|
+
says: s.says,
|
|
293
|
+
details: detailsOf(t, i, n),
|
|
294
|
+
signer: t.signer,
|
|
295
|
+
done: done.has(s.id),
|
|
296
|
+
})),
|
|
297
|
+
)
|
|
298
|
+
: [];
|
|
235
299
|
for (const s of steps) said.set(s.id, s);
|
|
236
300
|
return {
|
|
237
301
|
name: spec.name ?? path.basename(file),
|
|
@@ -240,13 +304,25 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
240
304
|
program: LOOT_PROGRAM,
|
|
241
305
|
cluster: 'devnet',
|
|
242
306
|
setupSol: cost(spec, { now: at, live: live(), only }).setup?.totalSol ?? null,
|
|
243
|
-
warnings: [
|
|
307
|
+
warnings: [
|
|
308
|
+
...(extra.refused ? [extra.refused] : []),
|
|
309
|
+
...verdict.warnings.filter(w => !Object.hasOwn(LEGAL, w.code)).map(w => w.message),
|
|
310
|
+
...[
|
|
311
|
+
settings,
|
|
312
|
+
unread(),
|
|
313
|
+
remadeLists(),
|
|
314
|
+
outsideAddresses(
|
|
315
|
+
steps.flatMap(s => s.details),
|
|
316
|
+
{ was: wasLines(), owner: n.owner },
|
|
317
|
+
),
|
|
318
|
+
].filter(Boolean),
|
|
319
|
+
],
|
|
244
320
|
// The legal flags in plain words, shown in a block of their own whatever the agent said: Approve waits until the
|
|
245
321
|
// owner ticks that they read them.
|
|
246
322
|
legal,
|
|
247
323
|
authority: model(spec).authority,
|
|
248
324
|
steps,
|
|
249
|
-
landed: [...done].map(
|
|
325
|
+
landed: [...done].map(id => ({ id, says: said.get(id)?.says ?? id, details: said.get(id)?.details ?? [] })),
|
|
250
326
|
signatures: [...signatures],
|
|
251
327
|
// The project the setup sends to, once a wallet is connected: always that wallet's own (or the one it is the
|
|
252
328
|
// authority of).
|
|
@@ -308,17 +384,26 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
308
384
|
const v = view(p0, namesOf(model(spec, { now: at }), { owner: wallet }), { refused: e.message });
|
|
309
385
|
return { ...v, remaining: p0.ok ? p0.transactions.length : 0, done: false };
|
|
310
386
|
}
|
|
311
|
-
if (!p0.ok || !p0.transactions.length)
|
|
387
|
+
if (!p0.ok || !p0.transactions.length)
|
|
388
|
+
return { ...view(p0, names(), { project: trusted.project }), remaining: 0, done: p0.ok };
|
|
312
389
|
const p = await build(trusted.owner);
|
|
313
390
|
await readLive(p);
|
|
314
|
-
const v = view(p, namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury }), {
|
|
391
|
+
const v = view(p, namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury }), {
|
|
392
|
+
project: p.ctx.project,
|
|
393
|
+
});
|
|
315
394
|
return { ...v, remaining: p.transactions.length, done: false };
|
|
316
395
|
}
|
|
317
396
|
|
|
318
397
|
async function prepareFor(wallet) {
|
|
319
398
|
// A second ask while a batch is out for signing (a double click) gets that same batch, not a new one that would
|
|
320
399
|
// make the first signature fail.
|
|
321
|
-
if (
|
|
400
|
+
if (
|
|
401
|
+
prepared &&
|
|
402
|
+
prepared.wallet === wallet &&
|
|
403
|
+
prepared.generation === generation &&
|
|
404
|
+
prepared.expires > clock() + 15_000
|
|
405
|
+
)
|
|
406
|
+
return answer(prepared);
|
|
322
407
|
await settlePending();
|
|
323
408
|
const trusted = await trust(wallet);
|
|
324
409
|
const p = await build(trusted.owner);
|
|
@@ -327,18 +412,24 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
327
412
|
const authority = p.ctx.authority;
|
|
328
413
|
const first = p.transactions[0];
|
|
329
414
|
const signer = first.signer === 'authority' ? authority : p.ctx.owner;
|
|
330
|
-
if (signer !== wallet)
|
|
415
|
+
if (signer !== wallet)
|
|
416
|
+
throw new ApproveError(
|
|
417
|
+
403,
|
|
418
|
+
first.signer === 'authority'
|
|
419
|
+
? `The next step is the authority’s: connect the wallet ${authority} and approve it there.`
|
|
420
|
+
: `The next step is the project owner’s: connect the wallet ${p.ctx.owner} and approve it there.`,
|
|
421
|
+
);
|
|
331
422
|
const batch = [];
|
|
332
423
|
for (const t of p.transactions) {
|
|
333
424
|
if (t.signer !== first.signer || batch.length >= PER_APPROVAL) break;
|
|
334
425
|
batch.push(t);
|
|
335
426
|
}
|
|
336
427
|
const { blockhash, lastValidBlockHeight } = await chain.latestBlockhash();
|
|
337
|
-
const wires = batch.map(
|
|
428
|
+
const wires = batch.map(t => {
|
|
338
429
|
const message = compileMessage({ payer: wallet, blockhash, instructions: t.instructions });
|
|
339
430
|
const { signers } = readTransaction(wireTransaction(message));
|
|
340
|
-
const sigs = signers.map(
|
|
341
|
-
const fresh = [...keys.values()].find(
|
|
431
|
+
const sigs = signers.map(s => {
|
|
432
|
+
const fresh = [...keys.values()].find(k => k.address === s);
|
|
342
433
|
return fresh ? fresh.sign(message) : new Uint8Array(64);
|
|
343
434
|
});
|
|
344
435
|
return wireTransaction(message, sigs);
|
|
@@ -346,13 +437,27 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
346
437
|
// What each transaction pays to whom, read back from the very instructions the wallet signs.
|
|
347
438
|
const n = namesOf(model(spec, { now: at }), { owner: wallet, treasury: p.ctx.treasury });
|
|
348
439
|
// Every line names the project it goes to first: always the wallet's own, or the one it is the authority of.
|
|
349
|
-
const details = batch.map(
|
|
350
|
-
|
|
440
|
+
const details = batch.map(t => [
|
|
441
|
+
`Project: ${p.ctx.project}${p.ctx.owner === wallet ? ' (your own)' : ` (owned by ${p.ctx.owner})`}`,
|
|
442
|
+
...t.steps.flatMap((_, i) => detailsOf(t, i, n)),
|
|
443
|
+
]);
|
|
444
|
+
prepared = {
|
|
445
|
+
id: randomBytes(12).toString('hex'),
|
|
446
|
+
wallet,
|
|
447
|
+
wires,
|
|
448
|
+
batch,
|
|
449
|
+
details,
|
|
450
|
+
ctx: p.ctx,
|
|
451
|
+
lastValidBlockHeight,
|
|
452
|
+
generation,
|
|
453
|
+
expires: clock() + PREPARE_TTL_MS,
|
|
454
|
+
};
|
|
351
455
|
return answer(prepared);
|
|
352
456
|
}
|
|
353
457
|
|
|
354
458
|
async function complete(wallet, id, signed) {
|
|
355
|
-
if (!prepared || !sameText(prepared.id, String(id ?? '')))
|
|
459
|
+
if (!prepared || !sameText(prepared.id, String(id ?? '')))
|
|
460
|
+
throw new ApproveError(409, 'That batch is not the one prepared: ask for the next one.');
|
|
356
461
|
if (prepared.expires < clock()) {
|
|
357
462
|
prepared = null;
|
|
358
463
|
throw new ApproveError(410, 'That batch expired (90 seconds). Ask for it again.');
|
|
@@ -360,7 +465,8 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
360
465
|
if (prepared.wallet !== wallet) throw new ApproveError(403, 'That batch was prepared for another wallet.');
|
|
361
466
|
const batch = prepared;
|
|
362
467
|
prepared = null;
|
|
363
|
-
if (batch.generation !== generation)
|
|
468
|
+
if (batch.generation !== generation)
|
|
469
|
+
throw new ApproveError(409, 'Something landed after that batch was built: ask for the next one.');
|
|
364
470
|
const wires = checkSigned(batch.wires, signed, wallet);
|
|
365
471
|
busy = true;
|
|
366
472
|
try {
|
|
@@ -370,7 +476,12 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
370
476
|
// sent twice (`settlePending`).
|
|
371
477
|
const signature = encodeBase58(readTransaction(wire).signatures[0]);
|
|
372
478
|
const sent = batch.batch[i];
|
|
373
|
-
await remember({
|
|
479
|
+
await remember({
|
|
480
|
+
signature,
|
|
481
|
+
steps: sent.steps.map(s => s.id),
|
|
482
|
+
made: madeBy(sent, batch.ctx),
|
|
483
|
+
lastValidBlockHeight: batch.lastValidBlockHeight,
|
|
484
|
+
});
|
|
374
485
|
try {
|
|
375
486
|
await chain.send(wire);
|
|
376
487
|
} catch (e) {
|
|
@@ -386,7 +497,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
386
497
|
const status = chain.signatureStatus ? await chain.signatureStatus(signature).catch(() => null) : null;
|
|
387
498
|
if (!landedStatus(status)) {
|
|
388
499
|
if (status?.err || !chain.signatureStatus) await forget(signature);
|
|
389
|
-
else
|
|
500
|
+
else
|
|
501
|
+
throw new ApproveError(
|
|
502
|
+
503,
|
|
503
|
+
`${e.message} It may still land: it is looked up on chain before anything is sent again, so approving again never sends it twice. Wait a minute, then ask for the next batch.`,
|
|
504
|
+
);
|
|
390
505
|
throw e;
|
|
391
506
|
}
|
|
392
507
|
}
|
|
@@ -401,13 +516,21 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
401
516
|
|
|
402
517
|
/** A prepared batch as the page gets it: the wires to sign, and what each does in words. */
|
|
403
518
|
function answer(b) {
|
|
404
|
-
return {
|
|
519
|
+
return {
|
|
520
|
+
id: b.id,
|
|
521
|
+
project: b.ctx.project,
|
|
522
|
+
transactions: b.wires.map(toBase64),
|
|
523
|
+
says: b.batch.map(t => t.steps.map(s => s.says).join('; ')),
|
|
524
|
+
details: b.details,
|
|
525
|
+
};
|
|
405
526
|
}
|
|
406
527
|
|
|
407
528
|
/** What a transaction's steps make, from the context it was built with: recorded when it lands. */
|
|
408
529
|
function madeBy(transaction, ctx) {
|
|
409
|
-
const ids = new Set(transaction.steps.map(
|
|
410
|
-
const mints = Object.fromEntries(
|
|
530
|
+
const ids = new Set(transaction.steps.map(s => s.id));
|
|
531
|
+
const mints = Object.fromEntries(
|
|
532
|
+
[...ids].filter(id => id.startsWith('token:')).map(id => [id.slice(6), ctx.mints[id.slice(6)]]),
|
|
533
|
+
);
|
|
411
534
|
return {
|
|
412
535
|
...(ids.has('project') ? { project: ctx.project, treasury: ctx.treasury, authority: ctx.authority } : {}),
|
|
413
536
|
...(ids.has('collection') && ctx.collection ? { collection: ctx.collection } : {}),
|
|
@@ -423,7 +546,16 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
423
546
|
*/
|
|
424
547
|
async function remember(entry) {
|
|
425
548
|
const was = spec.live ?? {};
|
|
426
|
-
spec = {
|
|
549
|
+
spec = {
|
|
550
|
+
...spec,
|
|
551
|
+
live: {
|
|
552
|
+
...was,
|
|
553
|
+
landed: was.landed ?? [],
|
|
554
|
+
partial: true,
|
|
555
|
+
...(only ? { changing: [...only] } : {}),
|
|
556
|
+
pending: [...(was.pending ?? []), entry].slice(-60),
|
|
557
|
+
},
|
|
558
|
+
};
|
|
427
559
|
generation++;
|
|
428
560
|
await write(file, `${JSON.stringify(spec, null, 2)}\n`);
|
|
429
561
|
}
|
|
@@ -431,7 +563,7 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
431
563
|
/** Takes a transaction out of the pending list: it landed (and is recorded), failed, or can no longer land. */
|
|
432
564
|
async function forget(signature) {
|
|
433
565
|
if (!spec.live?.pending) return;
|
|
434
|
-
const pending = spec.live.pending.filter(
|
|
566
|
+
const pending = spec.live.pending.filter(p => p.signature !== signature);
|
|
435
567
|
const { pending: _, ...rest } = spec.live;
|
|
436
568
|
spec = { ...spec, live: pending.length ? { ...rest, pending } : rest };
|
|
437
569
|
generation++;
|
|
@@ -442,7 +574,9 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
442
574
|
async function projectOwner(project) {
|
|
443
575
|
if (!project || !chain.account) return null;
|
|
444
576
|
const account = await chain.account(project).catch(() => null);
|
|
445
|
-
return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project'
|
|
577
|
+
return account && account.owner === LOOT_PROGRAM && accountKind(account.data) === 'Project'
|
|
578
|
+
? decode('Project', account.data).owner
|
|
579
|
+
: null;
|
|
446
580
|
}
|
|
447
581
|
|
|
448
582
|
/**
|
|
@@ -459,7 +593,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
459
593
|
const status = await chain.signatureStatus(entry.signature);
|
|
460
594
|
if (landedStatus(status)) {
|
|
461
595
|
if (!signatures.includes(entry.signature)) signatures.push(entry.signature);
|
|
462
|
-
await landed(
|
|
596
|
+
await landed(
|
|
597
|
+
{ steps: entry.steps.map(id => ({ id })) },
|
|
598
|
+
{ ...(entry.made ?? {}), owner: await projectOwner(entry.made?.project ?? spec.live?.project) },
|
|
599
|
+
entry.signature,
|
|
600
|
+
);
|
|
463
601
|
continue;
|
|
464
602
|
}
|
|
465
603
|
if (status?.err) {
|
|
@@ -472,7 +610,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
472
610
|
await forget(entry.signature);
|
|
473
611
|
continue;
|
|
474
612
|
}
|
|
475
|
-
throw new ApproveError(
|
|
613
|
+
throw new ApproveError(
|
|
614
|
+
409,
|
|
615
|
+
`The transaction ${entry.signature}, sent earlier, has not landed yet and may still: wait a minute and try again, so nothing is sent twice.`,
|
|
616
|
+
);
|
|
476
617
|
}
|
|
477
618
|
}
|
|
478
619
|
|
|
@@ -483,10 +624,11 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
483
624
|
*/
|
|
484
625
|
async function landed(transaction, ctx, signature) {
|
|
485
626
|
const was = spec.live ?? {};
|
|
486
|
-
const ids = transaction.steps.map(
|
|
627
|
+
const ids = transaction.steps.map(s => s.id);
|
|
487
628
|
const made = new Set(ids);
|
|
488
629
|
const mints = { ...(was.mints ?? {}) };
|
|
489
|
-
for (const id of ids)
|
|
630
|
+
for (const id of ids)
|
|
631
|
+
if (id.startsWith('token:') && ctx.mints?.[id.slice(6)]) mints[id.slice(6)] = ctx.mints[id.slice(6)];
|
|
490
632
|
const progress = {
|
|
491
633
|
...was,
|
|
492
634
|
program: LOOT_PROGRAM,
|
|
@@ -499,8 +641,10 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
499
641
|
partial: true,
|
|
500
642
|
...(only ? { changing: [...only] } : {}),
|
|
501
643
|
// (each signature once: one found again by `settlePending` may have been recorded already)
|
|
502
|
-
transactions: (was.transactions ?? []).some(
|
|
503
|
-
|
|
644
|
+
transactions: (was.transactions ?? []).some(t => t.signature === signature)
|
|
645
|
+
? was.transactions
|
|
646
|
+
: [...(was.transactions ?? []), { signature }].slice(-60),
|
|
647
|
+
pending: (was.pending ?? []).filter(p => p.signature !== signature),
|
|
504
648
|
};
|
|
505
649
|
if (!progress.pending.length) delete progress.pending;
|
|
506
650
|
if (!Object.keys(progress.mints).length) delete progress.mints;
|
|
@@ -511,7 +655,13 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
511
655
|
if (rest.ok && rest.transactions.length === 0) {
|
|
512
656
|
const { live: _, ...bare } = spec;
|
|
513
657
|
// Everything the setup made, whichever session or transaction made it.
|
|
514
|
-
const all = {
|
|
658
|
+
const all = {
|
|
659
|
+
...ctx,
|
|
660
|
+
...Object.fromEntries(
|
|
661
|
+
['project', 'treasury', 'authority', 'collection', 'tree', 'tables'].map(k => [k, progress[k] ?? ctx[k]]),
|
|
662
|
+
),
|
|
663
|
+
mints: { ...(ctx.mints ?? {}), ...(progress.mints ?? {}) },
|
|
664
|
+
};
|
|
515
665
|
const final = recordLanded(bare, { ctx: all, now: at, at: new Date(clock()).toISOString() });
|
|
516
666
|
final.live = { ...final.live, transactions: progress.transactions };
|
|
517
667
|
spec = final;
|
|
@@ -529,12 +679,19 @@ export async function createApproval({ file, chain = devnetChain(), now = () =>
|
|
|
529
679
|
function boundedRent(bytes, lamports) {
|
|
530
680
|
const most = (BigInt(bytes) + ACCOUNT_OVERHEAD) * MOST_RENT_PER_BYTE;
|
|
531
681
|
const given = BigInt(lamports);
|
|
532
|
-
if (given > most)
|
|
682
|
+
if (given > most)
|
|
683
|
+
throw new ApproveError(
|
|
684
|
+
502,
|
|
685
|
+
`The RPC says an account of ${bytes} bytes needs ${given} lamports of rent; no cluster charges more than ${most}. Nothing was sent: use another RPC (XSOLANA_RPC_URL).`,
|
|
686
|
+
);
|
|
533
687
|
return given;
|
|
534
688
|
}
|
|
535
689
|
|
|
536
690
|
/** Whether a signature's status says it landed: confirmed or finalized, and not failed. */
|
|
537
|
-
const landedStatus =
|
|
691
|
+
const landedStatus = status =>
|
|
692
|
+
Boolean(
|
|
693
|
+
status && !status.err && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized'),
|
|
694
|
+
);
|
|
538
695
|
|
|
539
696
|
const sameText = (a, b) => {
|
|
540
697
|
const x = Buffer.from(String(a));
|
|
@@ -544,7 +701,8 @@ const sameText = (a, b) => {
|
|
|
544
701
|
|
|
545
702
|
/** The wallet's signed copies: each the exact prepared message, each with the wallet's valid signature. */
|
|
546
703
|
function checkSigned(prepared, signed, wallet) {
|
|
547
|
-
if (!Array.isArray(signed) || signed.length !== prepared.length)
|
|
704
|
+
if (!Array.isArray(signed) || signed.length !== prepared.length)
|
|
705
|
+
throw new ApproveError(400, 'The wallet’s answer does not match the request.');
|
|
548
706
|
return prepared.map((wire, i) => {
|
|
549
707
|
let mine;
|
|
550
708
|
let theirs;
|
|
@@ -554,11 +712,13 @@ function checkSigned(prepared, signed, wallet) {
|
|
|
554
712
|
} catch {
|
|
555
713
|
throw new ApproveError(400, 'The wallet returned something that is not a transaction.');
|
|
556
714
|
}
|
|
557
|
-
if (!equalBytes(mine.message, theirs.message))
|
|
715
|
+
if (!equalBytes(mine.message, theirs.message))
|
|
716
|
+
throw new ApproveError(400, 'The wallet changed the transaction before signing it, so nothing was sent.');
|
|
558
717
|
const slot = mine.signers.indexOf(wallet);
|
|
559
718
|
if (slot < 0) throw new ApproveError(400, 'This transaction was not prepared for that wallet.');
|
|
560
719
|
const signature = theirs.signatures[slot];
|
|
561
|
-
if (!verifySignature(wallet, mine.message, signature))
|
|
720
|
+
if (!verifySignature(wallet, mine.message, signature))
|
|
721
|
+
throw new ApproveError(400, 'The wallet’s signature is missing or does not match your wallet.');
|
|
562
722
|
const signatures = [...mine.signatures];
|
|
563
723
|
signatures[slot] = signature;
|
|
564
724
|
return wireTransaction(mine.message, signatures);
|
|
@@ -589,7 +749,7 @@ export function ownRequest(headers, port) {
|
|
|
589
749
|
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
590
750
|
if (!hosts.includes(String(headers.host ?? '').toLowerCase())) return false;
|
|
591
751
|
if (headers.origin === undefined) return true;
|
|
592
|
-
return hosts.some(
|
|
752
|
+
return hosts.some(h => String(headers.origin).toLowerCase() === `http://${h}`);
|
|
593
753
|
}
|
|
594
754
|
|
|
595
755
|
/** A request's body, as text: refused (413) past `limit` bytes, and the rest of it read and let go. */
|
|
@@ -597,7 +757,7 @@ function readBody(req, limit = BODY_LIMIT) {
|
|
|
597
757
|
return new Promise((resolve, reject) => {
|
|
598
758
|
const chunks = [];
|
|
599
759
|
let size = 0;
|
|
600
|
-
req.on('data',
|
|
760
|
+
req.on('data', chunk => {
|
|
601
761
|
size += chunk.length;
|
|
602
762
|
if (size > limit) return reject(new ApproveError(413, 'That request is too large.'));
|
|
603
763
|
chunks.push(chunk);
|
|
@@ -629,9 +789,18 @@ export async function openBrowser(url, { spawnImpl = spawn, dir = tmpdir() } = {
|
|
|
629
789
|
try {
|
|
630
790
|
await chmod(folder, 0o700);
|
|
631
791
|
const file = path.join(folder, 'open.html');
|
|
632
|
-
const href = url.replace(/[&<>"']/g,
|
|
633
|
-
await writeFile(
|
|
634
|
-
|
|
792
|
+
const href = url.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
793
|
+
await writeFile(
|
|
794
|
+
file,
|
|
795
|
+
`<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${href}"><title>xsolana approve</title><a href="${href}">Open the approval page</a>\n`,
|
|
796
|
+
{ mode: 0o600, flag: 'wx' },
|
|
797
|
+
);
|
|
798
|
+
const [cmd, args] =
|
|
799
|
+
process.platform === 'darwin'
|
|
800
|
+
? ['open', [file]]
|
|
801
|
+
: process.platform === 'win32'
|
|
802
|
+
? ['cmd', ['/c', 'start', '', file]]
|
|
803
|
+
: ['xdg-open', [file]];
|
|
635
804
|
spawnImpl(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
636
805
|
} catch {
|
|
637
806
|
/* the link is printed anyway */
|
|
@@ -646,16 +815,33 @@ export async function openBrowser(url, { spawnImpl = spawn, dir = tmpdir() } = {
|
|
|
646
815
|
* after. The default `openLink` keeps even the code off the opener's command line (`openBrowser`); one that answers a
|
|
647
816
|
* function has it called once the code is used.
|
|
648
817
|
*/
|
|
649
|
-
export async function approve({
|
|
818
|
+
export async function approve({
|
|
819
|
+
file = 'xsolana.json',
|
|
820
|
+
port,
|
|
821
|
+
open = true,
|
|
822
|
+
chain = devnetChain(),
|
|
823
|
+
log = (...a) => console.error(...a),
|
|
824
|
+
openLink = openBrowser,
|
|
825
|
+
} = {}) {
|
|
650
826
|
const session = await createApproval({ file: path.resolve(file), chain });
|
|
651
827
|
const token = randomBytes(18).toString('base64url');
|
|
652
828
|
let code = randomBytes(18).toString('base64url');
|
|
653
829
|
// (removes the file the browser was opened with, once: `openLink` answers how)
|
|
654
830
|
let removeOpener = null;
|
|
655
|
-
const forget = () => {
|
|
831
|
+
const forget = () => {
|
|
832
|
+
const remove = removeOpener;
|
|
833
|
+
removeOpener = null;
|
|
834
|
+
remove?.();
|
|
835
|
+
};
|
|
656
836
|
const page = await readFile(path.join(ROOT, 'approve/page.html'), 'utf8');
|
|
657
837
|
const policy = pagePolicy(page);
|
|
658
|
-
const headers = {
|
|
838
|
+
const headers = {
|
|
839
|
+
'cache-control': 'no-store',
|
|
840
|
+
'x-content-type-options': 'nosniff',
|
|
841
|
+
'referrer-policy': 'no-referrer',
|
|
842
|
+
'content-security-policy': policy,
|
|
843
|
+
'x-frame-options': 'DENY',
|
|
844
|
+
};
|
|
659
845
|
const server = createServer(async (req, res) => {
|
|
660
846
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
661
847
|
const send = (status, body, type = 'application/json') => {
|
|
@@ -664,27 +850,41 @@ export async function approve({ file = 'xsolana.json', port, open = true, chain
|
|
|
664
850
|
};
|
|
665
851
|
try {
|
|
666
852
|
// Only a request to this server by its own name (DNS rebinding: a site whose name now points at 127.0.0.1).
|
|
667
|
-
if (!ownRequest(req.headers, server.address().port))
|
|
853
|
+
if (!ownRequest(req.headers, server.address().port))
|
|
854
|
+
return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
|
|
668
855
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
669
856
|
const once = url.searchParams.get('c');
|
|
670
857
|
if (once !== null) {
|
|
671
|
-
if (!code || !sameText(once, code))
|
|
858
|
+
if (!code || !sameText(once, code))
|
|
859
|
+
return send(
|
|
860
|
+
403,
|
|
861
|
+
'This link has been opened already: open the link `xsolana approve` printed.',
|
|
862
|
+
'text/plain',
|
|
863
|
+
);
|
|
672
864
|
code = null;
|
|
673
865
|
forget();
|
|
674
866
|
res.writeHead(303, { ...headers, location: `/?t=${token}` });
|
|
675
867
|
return res.end();
|
|
676
868
|
}
|
|
677
|
-
if (!sameText(url.searchParams.get('t') ?? '', token))
|
|
869
|
+
if (!sameText(url.searchParams.get('t') ?? '', token))
|
|
870
|
+
return send(403, 'Open the link `xsolana approve` printed.', 'text/plain');
|
|
678
871
|
return send(200, page.replace('__TOKEN__', token), 'text/html; charset=utf-8');
|
|
679
872
|
}
|
|
680
|
-
if (req.method === 'GET' && BROWSER_FILES.test(url.pathname))
|
|
681
|
-
|
|
873
|
+
if (req.method === 'GET' && BROWSER_FILES.test(url.pathname))
|
|
874
|
+
return send(
|
|
875
|
+
200,
|
|
876
|
+
await readFile(path.join(ROOT, url.pathname.slice(4)), 'utf8'),
|
|
877
|
+
'text/javascript; charset=utf-8',
|
|
878
|
+
);
|
|
879
|
+
if (!sameText(req.headers['x-xsolana-token'] ?? '', token))
|
|
880
|
+
return send(403, { error: 'Not this session’s page.' });
|
|
682
881
|
if (req.method === 'GET' && url.pathname === '/api/state') {
|
|
683
882
|
const wallet = url.searchParams.get('wallet');
|
|
684
883
|
return send(200, wallet ? await session.stateFor(wallet) : session.state());
|
|
685
884
|
}
|
|
686
885
|
const body = req.method === 'POST' ? await readJson(req) : {};
|
|
687
|
-
if (req.method === 'POST' && url.pathname === '/api/prepare')
|
|
886
|
+
if (req.method === 'POST' && url.pathname === '/api/prepare')
|
|
887
|
+
return send(200, await session.prepare(String(body.wallet ?? '')));
|
|
688
888
|
if (req.method === 'POST' && url.pathname === '/api/complete') {
|
|
689
889
|
const state = await session.complete(String(body.wallet ?? ''), body.id, body.transactions);
|
|
690
890
|
if (state.done) {
|
|
@@ -712,7 +912,9 @@ export async function approve({ file = 'xsolana.json', port, open = true, chain
|
|
|
712
912
|
const link = `${origin}/?t=${token}`;
|
|
713
913
|
const opened = `${origin}/?c=${code}`;
|
|
714
914
|
log(`Approve going live in your wallet (devnet): ${link}`);
|
|
715
|
-
log(
|
|
915
|
+
log(
|
|
916
|
+
'Leave this running until the page says it is live. Ctrl+C stops it; running it again goes on from where it stopped.',
|
|
917
|
+
);
|
|
716
918
|
if (open && !process.env.XSOLANA_NO_OPEN) {
|
|
717
919
|
const remove = await openLink(opened);
|
|
718
920
|
if (typeof remove === 'function') {
|