@xgamesfun/solana 0.0.0-stage → 0.24.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/DISCLAIMER.md +41 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +172 -2
- package/bin/xsolana.mjs +189 -0
- package/examples/artwork-auction.json +51 -0
- package/examples/blind-forge.json +128 -0
- package/examples/coin-flip-arena.json +103 -0
- package/examples/duel-arena.json +137 -0
- package/examples/elements-set.json +274 -0
- package/examples/event-badges.json +67 -0
- package/examples/expedition-guild.json +154 -0
- package/examples/fair-presale.json +48 -0
- package/examples/falling-drop.json +90 -0
- package/examples/guild-missions.json +137 -0
- package/examples/halving-seasons.json +107 -0
- package/examples/hero-trials.json +93 -0
- package/examples/ideas.json +789 -0
- package/examples/level-ladder.json +65 -0
- package/examples/merge-2048.json +128 -0
- package/examples/nft-raffle.json +33 -0
- package/examples/paper-hands-farm.json +44 -0
- package/examples/partner-vault.json +112 -0
- package/examples/sacrifice-raffle.json +118 -0
- package/examples/scorched-treasury.json +84 -0
- package/examples/shelter-fair.json +164 -0
- package/examples/spark-pity.json +153 -0
- package/examples/stakers-draw.json +118 -0
- package/examples/star-fleet.json +150 -0
- package/examples/starforce.json +116 -0
- package/examples/stud-farm.json +134 -0
- package/examples/team-vesting.json +70 -0
- package/examples/trade-in-shop.json +168 -0
- package/js/approve/details.mjs +263 -0
- package/js/approve/ed25519.mjs +36 -0
- package/js/approve/live-check.mjs +144 -0
- package/js/approve/page.html +86 -0
- package/js/approve/page.js +203 -0
- package/js/approve/server.mjs +726 -0
- package/js/chain/base58.js +73 -0
- package/js/chain/borsh.js +270 -0
- package/js/chain/bytes.js +141 -0
- package/js/chain/core.js +181 -0
- package/js/chain/curve.js +52 -0
- package/js/chain/lookup.js +279 -0
- package/js/chain/mint.js +96 -0
- package/js/chain/pda.js +66 -0
- package/js/chain/programs.js +102 -0
- package/js/chain/rpc.js +219 -0
- package/js/chain/sha256.js +68 -0
- package/js/chain/token.js +85 -0
- package/js/chain/token2022.js +247 -0
- package/js/chain/tx.js +224 -0
- package/js/client/accounts.js +679 -0
- package/js/client/compressed.js +46 -0
- package/js/client/grant.js +299 -0
- package/js/client/idl/loot.js +2 -0
- package/js/client/idl/loot.json +17206 -0
- package/js/client/index.js +10 -0
- package/js/client/ix.js +2887 -0
- package/js/client/pda.js +114 -0
- package/js/client/policy.js +69 -0
- package/js/client/random.js +70 -0
- package/js/client/sizes.js +297 -0
- package/js/client/traits.js +44 -0
- package/js/connect/connect.mjs +182 -0
- package/js/economy/check.mjs +2236 -0
- package/js/economy/cost.mjs +185 -0
- package/js/economy/examples.mjs +114 -0
- package/js/economy/explain.mjs +239 -0
- package/js/economy/guide.mjs +232 -0
- package/js/economy/legal.mjs +15 -0
- package/js/economy/live.mjs +130 -0
- package/js/economy/model.mjs +698 -0
- package/js/economy/plan.mjs +1155 -0
- package/js/economy/schema.json +3071 -0
- package/js/economy/secrets.mjs +60 -0
- package/js/economy/simulate.mjs +1019 -0
- package/js/economy/txsize.mjs +45 -0
- package/js/economy/validate.mjs +85 -0
- package/js/index.js +10 -0
- package/js/mcp/server.mjs +113 -0
- package/js/mcp/tools.mjs +485 -0
- package/js/play/index.js +2855 -0
- package/js/play/keys.js +167 -0
- package/js/results/index.js +184 -0
- package/js/results/wire.js +90 -0
- package/package.json +100 -4
- package/release.json +8 -0
- package/skills/xsolana-economy/SKILL.md +109 -0
- package/skills/xsolana-economy/references/after-launch.md +124 -0
- package/skills/xsolana-economy/references/apps.md +193 -0
- package/skills/xsolana-economy/references/sentence-to-spec.md +180 -0
- package/skills/xsolana-economy/references/telling-the-person.md +134 -0
- package/types/chain/base58.d.ts +10 -0
- package/types/chain/borsh.d.ts +23 -0
- package/types/chain/bytes.d.ts +45 -0
- package/types/chain/core.d.ts +52 -0
- package/types/chain/curve.d.ts +1 -0
- package/types/chain/lookup.d.ts +194 -0
- package/types/chain/mint.d.ts +25 -0
- package/types/chain/pda.d.ts +25 -0
- package/types/chain/programs.d.ts +65 -0
- package/types/chain/rpc.d.ts +68 -0
- package/types/chain/sha256.d.ts +1 -0
- package/types/chain/token.d.ts +141 -0
- package/types/chain/token2022.d.ts +53 -0
- package/types/chain/tx.d.ts +60 -0
- package/types/client/accounts.d.ts +318 -0
- package/types/client/compressed.d.ts +29 -0
- package/types/client/grant.d.ts +170 -0
- package/types/client/idl/loot.d.ts +4498 -0
- package/types/client/index.d.ts +9 -0
- package/types/client/ix.d.ts +5315 -0
- package/types/client/pda.d.ts +95 -0
- package/types/client/policy.d.ts +48 -0
- package/types/client/random.d.ts +26 -0
- package/types/client/sizes.d.ts +101 -0
- package/types/client/traits.d.ts +12 -0
- package/types/economy/check.d.mts +100 -0
- package/types/economy/cost.d.mts +69 -0
- package/types/economy/examples.d.mts +27 -0
- package/types/economy/explain.d.mts +19 -0
- package/types/economy/guide.d.mts +140 -0
- package/types/economy/legal.d.mts +12 -0
- package/types/economy/live.d.mts +124 -0
- package/types/economy/model.d.mts +431 -0
- package/types/economy/plan.d.mts +1014 -0
- package/types/economy/secrets.d.mts +4 -0
- package/types/economy/simulate.d.mts +164 -0
- package/types/economy/txsize.d.mts +17 -0
- package/types/economy/validate.d.mts +3 -0
- package/types/index.d.ts +9 -0
- package/types/mcp/tools.d.mts +635 -0
- package/types/play/index.d.ts +880 -0
- package/types/play/keys.d.ts +51 -0
- package/types/results/index.d.ts +155 -0
- package/types/results/wire.d.ts +122 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// What the spec file's `live` section says is made, held against the chain before anything is built on it. The file is
|
|
2
|
+
// written by agents, by hand and by teammates, so its `live` section is no more trusted than the rest of it: a planted
|
|
3
|
+
// one could name someone else's project (the owner's seeds would pay into its pools) or another treasury. The project
|
|
4
|
+
// must be the connected wallet's own, at the address its number makes, and the project, the mints, the collection and
|
|
5
|
+
// the tree the file calls made must be on chain and be that project's.
|
|
6
|
+
import { encodeBase58 } from '../chain/base58.js';
|
|
7
|
+
import { parseCoreCollection, MPL_CORE_PROGRAM } from '../chain/core.js';
|
|
8
|
+
import { parseMint } from '../chain/mint.js';
|
|
9
|
+
import { mayStartFrozen } from '../chain/token2022.js';
|
|
10
|
+
import { accountKind, decode } from '../client/accounts.js';
|
|
11
|
+
import { BUBBLEGUM_PROGRAM, LOOT_PROGRAM, pda } from '../client/pda.js';
|
|
12
|
+
import { SYSTEM_PROGRAM } from '../chain/pda.js';
|
|
13
|
+
import { madeSoFar } from '../economy/plan.mjs';
|
|
14
|
+
|
|
15
|
+
/** A `live` section that does not match the chain: the message says what differs, in plain words. */
|
|
16
|
+
export class LiveMismatch extends Error {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks `live` (the spec's live section) for `wallet`, the wallet connected on the page, against `chain` ({ account }).
|
|
20
|
+
* `m` is the spec's model. Returns { owner, project }: the project's owner the setup is built for (the wallet, or the
|
|
21
|
+
* project's owner when the wallet is its authority), and the project's address. Throws LiveMismatch when the file
|
|
22
|
+
* names a project that is not this wallet's, or an account the chain does not have as the file says.
|
|
23
|
+
*/
|
|
24
|
+
export async function checkLive({ live, m, wallet, chain }) {
|
|
25
|
+
const made = madeSoFar(live) ?? {};
|
|
26
|
+
const own = pda.project(wallet, m.projectNumber);
|
|
27
|
+
const setupMints = Object.entries(made.mints ?? {}).filter(([key]) => {
|
|
28
|
+
const t = m.tokens.get(key);
|
|
29
|
+
// Only a mint the setup makes is checked: a mint the spec names by its address is the spec's to name.
|
|
30
|
+
return t?.source === 'game' && (!t.mint || t.mint.startsWith('@'));
|
|
31
|
+
});
|
|
32
|
+
if (!made.project && !setupMints.length && !made.collection && !made.tree) return { owner: wallet, project: own };
|
|
33
|
+
if (typeof chain.account !== 'function') throw new Error('The file says part of this economy is live already, and this connection cannot read the chain to check it: nothing was sent.');
|
|
34
|
+
const read = (address) => chain.account(address);
|
|
35
|
+
|
|
36
|
+
let owner = wallet;
|
|
37
|
+
let project = own;
|
|
38
|
+
if (made.project) {
|
|
39
|
+
const account = await read(made.project);
|
|
40
|
+
if (!account || account.owner !== LOOT_PROGRAM || accountKind(account.data) !== 'Project') throw new LiveMismatch(`The file names the project ${made.project}, and there is no loot project at that address.`);
|
|
41
|
+
const state = decode('Project', account.data);
|
|
42
|
+
if (pda.project(state.owner, m.projectNumber) !== made.project) throw new LiveMismatch(`The file names the project ${made.project}, which is not project number ${m.projectNumber} of its owner ${state.owner}.`);
|
|
43
|
+
if (state.owner !== wallet && state.authority !== wallet) throw new LiveMismatch(`The file names the project ${made.project}, which belongs to the wallet ${state.owner}, not to the connected wallet ${wallet}. Your own project number ${m.projectNumber} is at ${own}.`);
|
|
44
|
+
if (made.treasury && made.treasury !== state.treasury) throw new LiveMismatch(`The file says the treasury is ${made.treasury}; on chain it is ${state.treasury}.`);
|
|
45
|
+
if (made.authority && made.authority !== state.authority) throw new LiveMismatch(`The file says the authority is ${made.authority}; on chain it is ${state.authority}.`);
|
|
46
|
+
owner = state.owner;
|
|
47
|
+
project = made.project;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [key, address] of setupMints) {
|
|
51
|
+
const t = m.tokens.get(key);
|
|
52
|
+
const account = await read(address);
|
|
53
|
+
const mint = account ? parseMint(address, account.owner, account.data) : null;
|
|
54
|
+
if (!mint) throw new LiveMismatch(`The file says the setup made the token ${t.symbol ?? key} at ${address}, and there is no mint there.`);
|
|
55
|
+
if (mint.decimals !== (t.decimals ?? 0)) throw new LiveMismatch(`The mint ${address} the file names for ${t.symbol ?? key} has ${mint.decimals} decimals, not ${t.decimals ?? 0}.`);
|
|
56
|
+
// The setup hands a token's minting to the project, or turns it off for good (a fixed supply); it never keeps a
|
|
57
|
+
// freeze authority.
|
|
58
|
+
const minter = t.fixedSupply ? null : project;
|
|
59
|
+
if (mint.mintAuthority !== minter) throw new LiveMismatch(`The mint ${address} the file names for ${t.symbol ?? key} is minted by ${mint.mintAuthority ?? 'nobody'}, not ${minter ?? 'nobody'} as the setup leaves it.`);
|
|
60
|
+
if (mint.freezeAuthority) throw new LiveMismatch(`The mint ${address} the file names for ${t.symbol ?? key} can be frozen by ${mint.freezeAuthority}; the setup makes none that can.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (made.collection) {
|
|
64
|
+
const account = await read(made.collection);
|
|
65
|
+
let collection;
|
|
66
|
+
try {
|
|
67
|
+
collection = account?.owner === MPL_CORE_PROGRAM ? parseCoreCollection(account.data) : null;
|
|
68
|
+
} catch {
|
|
69
|
+
collection = null;
|
|
70
|
+
}
|
|
71
|
+
if (!collection) throw new LiveMismatch(`The file names the collection ${made.collection}, and there is no Core collection there.`);
|
|
72
|
+
if (collection.updateAuthority !== owner) throw new LiveMismatch(`The collection ${made.collection} the file names belongs to ${collection.updateAuthority}, not to the project's owner ${owner}.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (made.tree) {
|
|
76
|
+
const config = await read(pda.treeConfig(made.tree));
|
|
77
|
+
const creator = config?.owner === BUBBLEGUM_PROGRAM && config.data.length >= 40 ? encodeBase58(config.data.subarray(8, 40)) : null;
|
|
78
|
+
if (creator !== project) throw new LiveMismatch(`The file names the tree ${made.tree}, which is not a tree of the project ${project}.`);
|
|
79
|
+
}
|
|
80
|
+
return { owner, project };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A token the spec names by its mint address that is not that mint on chain: the message says what differs. */
|
|
84
|
+
export class MintMismatch extends Error {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Checks every token the spec names by its mint address (an existing token, or a game token on a mint of its own)
|
|
88
|
+
* against `chain` ({ account }): the mint must be there, of the token program the spec says, with the decimals the
|
|
89
|
+
* spec says. Every amount the page shows is written in base units with the spec's decimals, and the program takes them
|
|
90
|
+
* as they are, so one decimal off is ten times what the page says. A mint the setup makes is made with the spec's
|
|
91
|
+
* decimals, and is not read. Throws MintMismatch.
|
|
92
|
+
*/
|
|
93
|
+
export async function checkMints({ m, chain }) {
|
|
94
|
+
const named = [...m.tokens.values()].filter((t) => t.mint && !t.mint.startsWith('@'));
|
|
95
|
+
// The routes that read a mint's default account state once, as they open, and refuse one that can start frozen.
|
|
96
|
+
const once = new Map();
|
|
97
|
+
const reads = (key, what) => key && !once.has(key) && once.set(key, what);
|
|
98
|
+
for (const l of m.lotteries) reads(l.currency ?? m.currencyKey, `the lottery ${l.name ?? l.key}`), reads(l.ticketCurrency, `the lottery ${l.name ?? l.key}`);
|
|
99
|
+
for (const c of m.contests) reads(c.currency ?? m.currencyKey, `the contest ${c.name ?? c.key}`);
|
|
100
|
+
for (const a of m.auctions) reads(a.currency ?? m.currencyKey, `the auction ${a.name ?? a.key}`);
|
|
101
|
+
for (const s of m.schedules.filter((x) => x.sealed)) reads(s.currency ?? m.currencyKey, `the sealed schedule ${s.name ?? s.key}`);
|
|
102
|
+
for (const t of named) {
|
|
103
|
+
const what = `the token ${t.symbol ?? t.key} (“${t.key}”)`;
|
|
104
|
+
if (t.wsol) {
|
|
105
|
+
if (t.decimals !== 9) throw new MintMismatch(`The spec gives ${what} ${t.decimals} decimals; its mint ${t.mint} is wrapped SOL, which has 9. Write "decimals": 9.`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (typeof chain.account !== 'function') throw new MintMismatch(`This connection cannot read the chain, so the decimals of ${what} at ${t.mint} cannot be checked.`);
|
|
109
|
+
const account = await chain.account(t.mint);
|
|
110
|
+
const mint = account ? parseMint(t.mint, account.owner, account.data) : null;
|
|
111
|
+
if (!mint) throw new MintMismatch(`The spec names ${what} at ${t.mint}, and there is no token mint there.`);
|
|
112
|
+
if (mint.decimals !== t.decimals) throw new MintMismatch(`The spec gives ${what} ${t.decimals ?? 'no'} decimals; its mint ${t.mint} has ${mint.decimals}, so every amount in it would be ${scale(t.decimals ?? 0, mint.decimals)} what the page shows. Write "decimals": ${mint.decimals}.`);
|
|
113
|
+
if (mint.program !== t.program) throw new MintMismatch(`The spec says ${what} is a ${t.program} token; its mint ${t.mint} is ${mint.program}. Write "program": "${mint.program}".`);
|
|
114
|
+
if (once.has(t.key) && mayStartFrozen(account))
|
|
115
|
+
throw new MintMismatch(`The mint ${t.mint} of ${what} has a default account state that starts new accounts frozen, or that its freeze authority can switch to frozen, so ${once.get(t.key)} would be refused as it opens (the program refuses such a mint wherever a refund or a payout goes into an account opened for it). Use another token there.`);
|
|
116
|
+
}
|
|
117
|
+
await checkTreasury({ m, chain });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Programs whose own accounts are often pasted where their vault was meant: a multisig's or a DAO's account. */
|
|
121
|
+
const HOLDS_A_VAULT = {
|
|
122
|
+
SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf: 'a Squads multisig’s own account, not its vault',
|
|
123
|
+
SMPLecH534NA9acpos4G6x7uf3LWbCAwZQE9e8ZekMu: 'a Squads v3 multisig’s own account, not its vault',
|
|
124
|
+
GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw: 'a Realms governance account, not its treasury',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The treasury the spec names ("wallet:<address>"), against the chain: what every
|
|
129
|
+
* split leaves goes to the treasury's own account of the mint, which only the treasury's key can move. A wallet, a
|
|
130
|
+
* Squads vault and a Realms treasury are System's accounts (or not there yet); an account another program owns (a
|
|
131
|
+
* multisig's own account, a token account, an NFT, a program) signs for nothing, so what went there would be stuck.
|
|
132
|
+
* Throws MintMismatch (what the spec names does not match the chain).
|
|
133
|
+
*/
|
|
134
|
+
export async function checkTreasury({ m, chain }) {
|
|
135
|
+
const treasury = m.treasuryWallet;
|
|
136
|
+
if (!treasury || typeof chain.account !== 'function') return;
|
|
137
|
+
const account = await chain.account(treasury);
|
|
138
|
+
if (!account || (account.owner === SYSTEM_PROGRAM && !account.executable)) return;
|
|
139
|
+
const what = HOLDS_A_VAULT[account.owner] ?? (account.executable ? 'a program' : `an account of the program ${account.owner}`);
|
|
140
|
+
throw new MintMismatch(`The spec’s treasury ${treasury} is ${what}: nobody can sign for what it would be paid, so every split’s rest would be stuck there. Name the wallet that signs (for a Squads multisig, its vault address; for a Realms DAO, its treasury).`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** How much an amount written with `said` decimals is, on a mint of `real` decimals, against what it says. */
|
|
144
|
+
const scale = (said, real) => (said > real ? `${10n ** BigInt(said - real)} times` : `1/${10n ** BigInt(real - said)} of`);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="xsolana-token" content="__TOKEN__">
|
|
7
|
+
<title>Approve going live · loot</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg: #f4f5f7; --panel: #ffffff; --ink: #14171f; --muted: #5b6372; --line: #dfe2e8; --accent: #3446d6; --ok: #1d7f4a; --bad: #b42318; --warn: #8a5a00; --mono: ui-monospace, "SFMono-Regular", Menlo, monospace; }
|
|
10
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0f1218; --panel: #171b23; --ink: #e8ebf2; --muted: #9aa3b5; --line: #2a303c; --accent: #8394ff; --ok: #4cc47f; --bad: #ff7a6b; --warn: #e8b04a; } }
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 32px max(16px, env(safe-area-inset-left)); }
|
|
13
|
+
main { max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
|
14
|
+
h1 { font-size: 1.5rem; margin: 0; }
|
|
15
|
+
.muted { color: var(--muted); }
|
|
16
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; }
|
|
17
|
+
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
18
|
+
button { font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
|
|
19
|
+
button.ghost { background: transparent; color: var(--accent); }
|
|
20
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
21
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
22
|
+
ol { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: 6px; }
|
|
23
|
+
li.done { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--ok); }
|
|
24
|
+
li .who { font-size: .8rem; color: var(--warn); margin-left: 6px; }
|
|
25
|
+
li .details { margin: 4px 0 2px; padding-left: 18px; font-size: .85rem; overflow-wrap: anywhere; }
|
|
26
|
+
code, .mono { font-family: var(--mono); font-size: .85em; overflow-wrap: anywhere; }
|
|
27
|
+
#log { font-family: var(--mono); font-size: .82rem; white-space: pre-wrap; margin: 0; max-height: 220px; overflow: auto; }
|
|
28
|
+
.ok { color: var(--ok); } .bad { color: var(--bad); }
|
|
29
|
+
ul.warn { margin: 0; padding-left: 18px; color: var(--warn); font-size: .9rem; }
|
|
30
|
+
.flush { margin: 0; } .note { margin: 10px 0 0; } .spread { justify-content: space-between; } .gap { margin-top: 12px; }
|
|
31
|
+
#wallets { margin-top: 10px; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<main>
|
|
36
|
+
<header>
|
|
37
|
+
<p class="muted mono flush">loot · devnet · runs on your computer only</p>
|
|
38
|
+
<h1 id="title">Approve going live</h1>
|
|
39
|
+
<p class="muted" id="sub">Loading…</p>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
<section class="panel">
|
|
43
|
+
<div class="row">
|
|
44
|
+
<button id="connect">Connect a wallet</button>
|
|
45
|
+
<span id="wallet" class="mono muted"></span>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="row" id="wallets" hidden></div>
|
|
48
|
+
<p class="muted note">Set the wallet to <b>devnet</b>. Each approval shows you the transactions before you sign; nothing leaves your wallet without it. Nobody else ever sees your key.</p>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section class="panel">
|
|
52
|
+
<div class="row spread">
|
|
53
|
+
<b>What going live does</b>
|
|
54
|
+
<span class="muted" id="cost"></span>
|
|
55
|
+
</div>
|
|
56
|
+
<ol id="steps" class="gap"></ol>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="panel" id="legalbox">
|
|
60
|
+
<b>Read this first</b>
|
|
61
|
+
<ul class="gap" id="terms">
|
|
62
|
+
<li>The loot program has not been independently audited. It and these tools come as is, with no warranty and no guarantee that they are safe: what is sent to the program can be lost. You use them at your own risk.</li>
|
|
63
|
+
<li>Transactions can't be undone. You are responsible for every one you approve.</li>
|
|
64
|
+
<li>Making sure your economy is legal everywhere it is offered is up to you. Nothing here is legal or financial advice.</li>
|
|
65
|
+
</ul>
|
|
66
|
+
<ul class="warn gap" id="legal" hidden></ul>
|
|
67
|
+
<p class="muted note">The full text is in DISCLAIMER.md, in the package.</p>
|
|
68
|
+
<label class="row gap"><input type="checkbox" id="legalok"> I have read this and accept it. Devnet is not real money; real money needs every point above answered first.</label>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
<section class="panel" id="warnbox" hidden>
|
|
72
|
+
<b>Before you approve</b>
|
|
73
|
+
<ul class="warn" id="warnings"></ul>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section class="panel">
|
|
77
|
+
<div class="row">
|
|
78
|
+
<button id="approve" disabled>Approve the next batch</button>
|
|
79
|
+
<span id="status" class="muted"></span>
|
|
80
|
+
</div>
|
|
81
|
+
<pre id="log"></pre>
|
|
82
|
+
</section>
|
|
83
|
+
</main>
|
|
84
|
+
<script type="module" src="/js/approve/page.js"></script>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The approval page, in the owner's own browser: the app side of the Wallet Standard (wallets such as Phantom,
|
|
2
|
+
// Solflare and Backpack register through two window events), then for each batch the server prepared: show it, have
|
|
3
|
+
// the wallet sign it, hand the signed copies back. The page never sees a key; the wallet signs. With more than one
|
|
4
|
+
// wallet in the browser, the person picks which; "Switch wallet" picks again. The page starts itself with the
|
|
5
|
+
// session's token from its <meta name="xsolana-token"> (the page allows no inline script).
|
|
6
|
+
import { fromBase64, toBase64 } from '/js/chain/bytes.js';
|
|
7
|
+
|
|
8
|
+
const CHAIN = 'solana:devnet';
|
|
9
|
+
const $ = (id) => document.getElementById(id);
|
|
10
|
+
|
|
11
|
+
function walletRegistry() {
|
|
12
|
+
const wallets = new Set();
|
|
13
|
+
const api = Object.freeze({
|
|
14
|
+
register: (...ws) => {
|
|
15
|
+
for (const w of ws) if (w?.features) wallets.add(w);
|
|
16
|
+
return () => ws.forEach((w) => wallets.delete(w));
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
window.addEventListener('wallet-standard:register-wallet', ({ detail }) => {
|
|
20
|
+
try {
|
|
21
|
+
detail(api);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error(e);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
window.dispatchEvent(Object.assign(new Event('wallet-standard:app-ready'), { detail: api }));
|
|
27
|
+
return () => [...wallets].filter((w) => (w.chains ?? []).some((c) => String(c).startsWith('solana:')) && w.features['standard:connect'] && w.features['solana:signTransaction']);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function approvalPage(token) {
|
|
31
|
+
const list = walletRegistry();
|
|
32
|
+
let wallet = null;
|
|
33
|
+
let account = null;
|
|
34
|
+
// Stops listening to the connected wallet's account changes.
|
|
35
|
+
let unlisten = null;
|
|
36
|
+
// While a batch is out for signing or sending, nothing re-enables Approve (a state refresh landing mid-way did).
|
|
37
|
+
let busy = false;
|
|
38
|
+
const api = async (path, body) => {
|
|
39
|
+
const r = await fetch(path, { method: body ? 'POST' : 'GET', headers: { 'x-xsolana-token': token, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
|
|
40
|
+
const data = await r.json().catch(() => ({}));
|
|
41
|
+
if (!r.ok) throw new Error(data.error ?? `HTTP ${r.status}`);
|
|
42
|
+
return data;
|
|
43
|
+
};
|
|
44
|
+
const log = (line, cls = '') => {
|
|
45
|
+
const el = document.createElement('div');
|
|
46
|
+
el.textContent = line;
|
|
47
|
+
if (cls) el.className = cls;
|
|
48
|
+
$('log').prepend(el);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// The last state shown: ticking the legal box shows it again, with Approve enabled or not.
|
|
52
|
+
let last = null;
|
|
53
|
+
function render(state) {
|
|
54
|
+
last = state;
|
|
55
|
+
$('title').textContent = state.done ? `“${state.name}” is live` : `Approve “${state.name}”`;
|
|
56
|
+
$('sub').textContent = state.done
|
|
57
|
+
? `Every step landed on devnet. ${state.file} now holds the project’s addresses: you can close this page.`
|
|
58
|
+
: `${state.mode === 'change' ? 'A change to a live economy' : state.mode === 'resume' ? 'A setup that was cut short goes on' : 'A new economy'} from ${state.file}, on the shared loot program ${state.program.slice(0, 6)}…${state.project ? `, in the project ${state.project}${state.wallet ? `, made from your wallet ${state.wallet}` : ''}` : ''}`;
|
|
59
|
+
$('cost').textContent = state.setupSol !== null && state.mode === 'new' ? `about ${state.setupSol} SOL` : '';
|
|
60
|
+
const steps = $('steps');
|
|
61
|
+
steps.replaceChildren(
|
|
62
|
+
...[...state.landed.map((l) => ({ ...l, done: true })), ...state.steps].map((s) => {
|
|
63
|
+
const li = document.createElement('li');
|
|
64
|
+
li.textContent = s.says;
|
|
65
|
+
if (s.done) li.className = 'done';
|
|
66
|
+
if (s.signer === 'authority') {
|
|
67
|
+
const who = document.createElement('span');
|
|
68
|
+
who.className = 'who';
|
|
69
|
+
who.textContent = `the authority signs (${state.authority})`;
|
|
70
|
+
li.append(who);
|
|
71
|
+
}
|
|
72
|
+
// Who it pays and which keys it trusts, read back from the transaction itself, addresses in full.
|
|
73
|
+
if (s.details?.length) {
|
|
74
|
+
const ul = document.createElement('ul');
|
|
75
|
+
ul.className = 'details';
|
|
76
|
+
ul.append(...s.details.map((d) => Object.assign(document.createElement('li'), { textContent: d })));
|
|
77
|
+
li.append(ul);
|
|
78
|
+
}
|
|
79
|
+
return li;
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
const warnings = state.warnings ?? [];
|
|
83
|
+
$('warnbox').hidden = !warnings.length || state.done;
|
|
84
|
+
$('warnings').replaceChildren(...warnings.map((w) => Object.assign(document.createElement('li'), { textContent: w })));
|
|
85
|
+
// The notice (no warranty, at the owner's own risk, legal where it runs) and this economy's legal flags, in a
|
|
86
|
+
// block of their own whatever the agent said: Approve waits until the owner ticks that they read them.
|
|
87
|
+
const legal = state.legal ?? [];
|
|
88
|
+
$('legalbox').hidden = Boolean(state.done);
|
|
89
|
+
$('legal').hidden = !legal.length;
|
|
90
|
+
$('legal').replaceChildren(...legal.map((f) => Object.assign(document.createElement('li'), { textContent: f.says })));
|
|
91
|
+
const unread = !$('legalok').checked;
|
|
92
|
+
// A live section the chain does not bear out: nothing can be approved.
|
|
93
|
+
$('approve').disabled = state.done || !account || busy || Boolean(state.refused) || unread;
|
|
94
|
+
$('status').textContent = state.done ? 'Done.' : state.refused ? 'Nothing can be approved: see above.' : !account ? 'Connect a wallet first.' : unread ? 'Read the notice above and tick that you have.' : `${state.remaining} transaction${state.remaining === 1 ? '' : 's'} to go.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function refresh() {
|
|
98
|
+
try {
|
|
99
|
+
// Once a wallet is connected, the steps are read back from what that wallet will sign.
|
|
100
|
+
render(await api(account ? `/api/state?wallet=${encodeURIComponent(account.address)}` : '/api/state'));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log(e.message, 'bad');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Connects `w` and makes its first account the one that approves; the account the wallet switches to follows. */
|
|
107
|
+
async function connectTo(w) {
|
|
108
|
+
// Never while a batch is out (the picker stays open meanwhile): the wallet asked to sign it hands it back.
|
|
109
|
+
if (busy) return;
|
|
110
|
+
$('wallets').hidden = true;
|
|
111
|
+
try {
|
|
112
|
+
const r = await w.features['standard:connect'].connect();
|
|
113
|
+
const next = r?.accounts?.[0] ?? w.accounts?.[0] ?? null;
|
|
114
|
+
if (!next) throw new Error('The wallet gave no account.');
|
|
115
|
+
unlisten?.();
|
|
116
|
+
unlisten = null;
|
|
117
|
+
wallet = w;
|
|
118
|
+
account = next;
|
|
119
|
+
const events = w.features['standard:events'];
|
|
120
|
+
if (events?.on) {
|
|
121
|
+
unlisten = events.on('change', ({ accounts }) => {
|
|
122
|
+
if (!accounts || wallet !== w || busy) return;
|
|
123
|
+
account = accounts[0] ?? null;
|
|
124
|
+
showWallet();
|
|
125
|
+
refresh();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
showWallet();
|
|
129
|
+
await refresh();
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(e.message, 'bad');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function showWallet() {
|
|
136
|
+
$('wallet').textContent = account ? `${wallet.name}: ${account.address}` : `${wallet.name}: no account connected`;
|
|
137
|
+
$('connect').textContent = 'Switch wallet';
|
|
138
|
+
$('connect').className = 'ghost';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
$('legalok').addEventListener('change', () => {
|
|
142
|
+
if (last) render(last);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// One wallet is connected at once; with several in the browser, the person picks one from a list.
|
|
146
|
+
$('connect').addEventListener('click', () => {
|
|
147
|
+
if (busy) return;
|
|
148
|
+
const found = list();
|
|
149
|
+
if (!found.length) {
|
|
150
|
+
log('No Solana wallet in this browser. Install one (Phantom, Solflare, Backpack), set it to devnet, then reload.', 'bad');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (found.length === 1) {
|
|
154
|
+
connectTo(found[0]);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const box = $('wallets');
|
|
158
|
+
box.replaceChildren(
|
|
159
|
+
Object.assign(document.createElement('span'), { className: 'muted', textContent: 'Pick a wallet:' }),
|
|
160
|
+
...found.map((w) => {
|
|
161
|
+
const button = Object.assign(document.createElement('button'), { className: 'ghost', textContent: w === wallet ? `${w.name} (connected)` : w.name });
|
|
162
|
+
button.addEventListener('click', () => connectTo(w));
|
|
163
|
+
return button;
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
box.hidden = false;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
$('approve').addEventListener('click', async () => {
|
|
170
|
+
if (busy || !account) return;
|
|
171
|
+
busy = true;
|
|
172
|
+
$('approve').disabled = true;
|
|
173
|
+
// The wallet and account this approval is for, read once: the batch is prepared for, signed by and handed back
|
|
174
|
+
// from the same one, whatever the wallet switches to meanwhile.
|
|
175
|
+
const signer = account;
|
|
176
|
+
const signingWallet = wallet;
|
|
177
|
+
try {
|
|
178
|
+
const batch = await api('/api/prepare', { wallet: signer.address });
|
|
179
|
+
const details = (batch.details ?? []).flat();
|
|
180
|
+
log(`Asking your wallet to approve ${batch.transactions.length} transaction${batch.transactions.length === 1 ? '' : 's'} in the project ${batch.project}: ${batch.says.join(' | ')}${details.length ? `. ${details.join('. ')}` : ''}`);
|
|
181
|
+
const sign = signingWallet.features['solana:signTransaction'];
|
|
182
|
+
const inputs = batch.transactions.map((t) => ({ account: signer, transaction: fromBase64(t), chain: CHAIN }));
|
|
183
|
+
const outputs = await sign.signTransaction(...inputs);
|
|
184
|
+
const signed = outputs.map((o) => toBase64(o.signedTransaction));
|
|
185
|
+
$('status').textContent = 'Sending and confirming…';
|
|
186
|
+
const state = await api('/api/complete', { wallet: signer.address, id: batch.id, transactions: signed });
|
|
187
|
+
log(`Landed: ${state.signatures.slice(-batch.transactions.length).join(', ')}`, 'ok');
|
|
188
|
+
busy = false;
|
|
189
|
+
render(state);
|
|
190
|
+
if (!state.done) await refresh();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
busy = false;
|
|
193
|
+
log(e.message, 'bad');
|
|
194
|
+
await refresh();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
refresh();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The page's own start: the session's token is in the page's <meta name="xsolana-token">.
|
|
202
|
+
const token = document.querySelector('meta[name="xsolana-token"]')?.content;
|
|
203
|
+
if (token) approvalPage(token);
|