@xgamesfun/solana 0.20.1 → 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.
Files changed (70) hide show
  1. package/README.md +10 -10
  2. package/examples/coin-flip-arena.json +2 -7
  3. package/examples/elements-set.json +2 -9
  4. package/examples/expedition-guild.json +1 -8
  5. package/examples/guild-missions.json +3 -10
  6. package/examples/halving-seasons.json +4 -9
  7. package/examples/hero-trials.json +1 -4
  8. package/examples/merge-2048.json +5 -5
  9. package/examples/paper-hands-farm.json +4 -16
  10. package/examples/partner-vault.json +1 -4
  11. package/examples/stakers-draw.json +1 -8
  12. package/examples/star-fleet.json +1 -8
  13. package/examples/starforce.json +6 -6
  14. package/js/approve/details.mjs +8 -7
  15. package/js/approve/server.mjs +20 -5
  16. package/js/chain/borsh.js +9 -0
  17. package/js/chain/core.js +14 -2
  18. package/js/chain/lookup.js +8 -2
  19. package/js/chain/rpc.js +4 -2
  20. package/js/client/accounts.js +448 -116
  21. package/js/client/grant.js +125 -66
  22. package/js/client/idl/loot.js +1 -1
  23. package/js/client/idl/loot.json +2360 -2218
  24. package/js/client/index.js +1 -0
  25. package/js/client/ix.js +1204 -473
  26. package/js/client/pda.js +20 -16
  27. package/js/client/policy.js +44 -23
  28. package/js/client/random.js +70 -0
  29. package/js/client/sizes.js +255 -12
  30. package/js/economy/check.mjs +455 -210
  31. package/js/economy/cost.mjs +45 -30
  32. package/js/economy/examples.mjs +2 -2
  33. package/js/economy/explain.mjs +24 -20
  34. package/js/economy/guide.mjs +31 -30
  35. package/js/economy/live.mjs +30 -7
  36. package/js/economy/model.mjs +171 -56
  37. package/js/economy/plan.mjs +380 -112
  38. package/js/economy/schema.json +291 -143
  39. package/js/economy/simulate.mjs +154 -87
  40. package/js/mcp/tools.mjs +14 -8
  41. package/js/play/index.js +1121 -375
  42. package/js/play/keys.js +167 -0
  43. package/package.json +1 -1
  44. package/release.json +5 -5
  45. package/skills/xsolana-economy/SKILL.md +15 -11
  46. package/skills/xsolana-economy/references/after-launch.md +37 -27
  47. package/skills/xsolana-economy/references/apps.md +55 -19
  48. package/skills/xsolana-economy/references/sentence-to-spec.md +30 -22
  49. package/skills/xsolana-economy/references/telling-the-person.md +52 -11
  50. package/types/chain/core.d.ts +3 -1
  51. package/types/chain/lookup.d.ts +5 -2
  52. package/types/chain/rpc.d.ts +3 -1
  53. package/types/client/accounts.d.ts +195 -55
  54. package/types/client/grant.d.ts +72 -27
  55. package/types/client/idl/loot.d.ts +590 -559
  56. package/types/client/index.d.ts +1 -0
  57. package/types/client/ix.d.ts +3862 -234
  58. package/types/client/pda.d.ts +19 -15
  59. package/types/client/policy.d.ts +39 -14
  60. package/types/client/random.d.ts +26 -0
  61. package/types/client/sizes.d.ts +93 -5
  62. package/types/economy/check.d.mts +5 -10
  63. package/types/economy/cost.d.mts +2 -1
  64. package/types/economy/live.d.mts +16 -1
  65. package/types/economy/model.d.mts +105 -37
  66. package/types/economy/plan.d.mts +193 -192
  67. package/types/economy/simulate.d.mts +21 -13
  68. package/types/mcp/tools.d.mts +21 -13
  69. package/types/play/index.d.ts +450 -39
  70. package/types/play/keys.d.ts +51 -0
@@ -0,0 +1,167 @@
1
+ // The throwaway keys js/play settles with (the key job rule, AGENTS.md), made so a page that closes in the middle of a
2
+ // job can find them again and finish it, or send their SOL back.
3
+ //
4
+ // A key is named by the account its job is about (an order, a hold). With a wallet that signs messages, every key comes
5
+ // from one signature of the wallet's over a fixed text naming the project and the wallet, asked once a session: the
6
+ // key's seed is sha256("loot settle key" | that signature | the name). Ed25519 signatures are deterministic, so after a
7
+ // reload the same signature gives the same keys. A wallet that signs no messages, or whose signature of the text
8
+ // changes from one session to the next (some MPC wallets), gets a random key instead, kept in the page's storage
9
+ // encrypted with a device key the page can use but never read out (WebCrypto, IndexedDB); with no such storage, in
10
+ // memory only, as before.
11
+ import { addressBytes, encodeBase58 } from '../chain/base58.js';
12
+ import { concat, fromBase64, randomBytes, toBase64, toHex, utf8 } from '../chain/bytes.js';
13
+ import { sha256 } from '../chain/sha256.js';
14
+
15
+ /** The PKCS #8 header of a raw 32-byte Ed25519 seed (RFC 8410). */
16
+ const PKCS8_ED25519 = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]);
17
+
18
+ const DERIVED = 'derived';
19
+ const fromBase64Url = (text) => fromBase64(text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '='));
20
+
21
+ /** An Ed25519 key from its 32-byte seed: { address, sign(message) }. */
22
+ export async function keyFromSeed(seed, name = null) {
23
+ const subtle = globalThis.crypto.subtle;
24
+ const pkcs8 = concat(PKCS8_ED25519, seed);
25
+ const jwk = await subtle.exportKey('jwk', await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']));
26
+ const signer = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
27
+ return { address: encodeBase58(fromBase64Url(jwk.x)), name, sign: async (message) => new Uint8Array(await subtle.sign('Ed25519', signer, message)) };
28
+ }
29
+
30
+ /** The text a wallet signs once a session for its settle keys. It moves nothing and says so. */
31
+ export const settleKeysText = (project, wallet) =>
32
+ `loot: the keys that settle your orders\nproject ${project}\nwallet ${wallet}\n\nSigning this moves nothing. A page that closes before it settles makes the same key again from it, to finish or send your SOL back.`;
33
+
34
+ /**
35
+ * The page's storage of keys that can't be made again (`localStorage`), and a device key that encrypts them, kept in
36
+ * IndexedDB as a non-extractable AES-GCM key. Null where either is missing (then keys live in memory only).
37
+ */
38
+ export function browserStore() {
39
+ const ls = globalThis.localStorage;
40
+ if (!ls || !globalThis.indexedDB) return null;
41
+ let device = null;
42
+ return {
43
+ get: (k) => ls.getItem(k),
44
+ set: (k, v) => ls.setItem(k, v),
45
+ remove: (k) => ls.removeItem(k),
46
+ keys: () => Array.from({ length: ls.length }, (_, i) => ls.key(i)).filter(Boolean),
47
+ cryptoKey: () => (device ??= deviceKey().catch(() => null)),
48
+ };
49
+ }
50
+
51
+ async function deviceKey() {
52
+ const db = await new Promise((resolve, reject) => {
53
+ const r = globalThis.indexedDB.open('loot-keys', 1);
54
+ r.onupgradeneeded = () => r.result.createObjectStore('keys');
55
+ r.onsuccess = () => resolve(r.result);
56
+ r.onerror = () => reject(r.error);
57
+ });
58
+ const found = await new Promise((resolve, reject) => {
59
+ const r = db.transaction('keys').objectStore('keys').get('device');
60
+ r.onsuccess = () => resolve(r.result ?? null);
61
+ r.onerror = () => reject(r.error);
62
+ });
63
+ if (found) return found;
64
+ const key = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
65
+ await new Promise((resolve, reject) => {
66
+ const tx = db.transaction('keys', 'readwrite');
67
+ tx.objectStore('keys').put(key, 'device');
68
+ tx.oncomplete = () => resolve();
69
+ tx.onerror = () => reject(tx.error);
70
+ });
71
+ return key;
72
+ }
73
+
74
+ /**
75
+ * The keys of `wallet` ({ address, signMessage? }) in `project`. `store`: `browserStore()`'s shape ({ get, set, remove,
76
+ * keys, cryptoKey }), or null. `key(name)` makes the key named by an address (asking the wallet for its signature the
77
+ * first time in a session); `again(name)` makes it again for a job left unfinished, or finds it stored, or null;
78
+ * `stored()` lists the names of keys stored; `forget(name)` drops a stored key once its job is done and it is empty.
79
+ * `how()` says how keys are made: 'derived', 'stored' or 'memory'.
80
+ */
81
+ export function keyring({ wallet, project, store = null }) {
82
+ let root = null;
83
+ const prefix = () => `loot-key:${project}:${wallet.address}:`;
84
+ const checkName = () => `loot-key-check:${project}:${wallet.address}`;
85
+ const safe = (f, fallback = null) => {
86
+ try {
87
+ return f();
88
+ } catch {
89
+ return fallback;
90
+ }
91
+ };
92
+
93
+ /** The wallet's signature of `settleKeysText`, once a session; null when it signs no messages, refuses, or signs the
94
+ * text differently from an earlier session (then nothing it signed could be made again). */
95
+ function signature() {
96
+ if (!wallet?.signMessage) return Promise.resolve(null);
97
+ root ??= (async () => {
98
+ const sig = new Uint8Array(await wallet.signMessage(utf8(settleKeysText(project, wallet.address))));
99
+ if (sig.length !== 64) return null;
100
+ const mark = toHex(sha256(sha256(sig))).slice(0, 32);
101
+ const had = store ? safe(() => store.get(checkName())) : null;
102
+ if (had && had !== mark) return null;
103
+ if (!had && store) safe(() => store.set(checkName(), mark));
104
+ return sig;
105
+ })().catch(() => null);
106
+ return root;
107
+ }
108
+
109
+ const derived = (sig, name) => keyFromSeed(sha256(concat(utf8('loot settle key'), sig, addressBytes(name))), name);
110
+
111
+ async function seal(seed) {
112
+ const device = await store?.cryptoKey?.();
113
+ if (!device) return null;
114
+ const iv = randomBytes(12);
115
+ const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, device, seed));
116
+ return toBase64(concat(iv, sealed));
117
+ }
118
+
119
+ async function open(text) {
120
+ const device = await store?.cryptoKey?.();
121
+ if (!device || !text) return null;
122
+ const bytes = fromBase64(text);
123
+ return new Uint8Array(await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, device, bytes.subarray(12)));
124
+ }
125
+
126
+ return {
127
+ async how() {
128
+ if (await signature()) return 'derived';
129
+ return store && (await store.cryptoKey?.()) ? 'stored' : 'memory';
130
+ },
131
+
132
+ /** The key named `name` (an address) for a new job. */
133
+ async key(name) {
134
+ const sig = await signature();
135
+ if (sig) {
136
+ // Its name is kept, so `recover` finds it once its job's account is gone.
137
+ if (store) safe(() => store.set(prefix() + name, DERIVED));
138
+ return derived(sig, name);
139
+ }
140
+ const seed = randomBytes(32);
141
+ const sealed = store ? await seal(seed).catch(() => null) : null;
142
+ if (sealed) safe(() => store.set(prefix() + name, sealed));
143
+ return keyFromSeed(seed, name);
144
+ },
145
+
146
+ /** The key named `name` again, for a job left unfinished: made again from the wallet's signature, or read from
147
+ * storage; null when neither can. `ask`: false to make it only from a signature already given this session. */
148
+ async again(name, { ask = true } = {}) {
149
+ const sig = ask || root ? await signature() : null;
150
+ if (sig) return derived(sig, name);
151
+ const text = store ? safe(() => store.get(prefix() + name)) : null;
152
+ const seed = text && text !== DERIVED ? await open(text).catch(() => null) : null;
153
+ return seed ? keyFromSeed(seed, name) : null;
154
+ },
155
+
156
+ /** The names of the keys this page stored for the wallet (none when keys are made from its signature). */
157
+ stored() {
158
+ if (!store) return [];
159
+ const p = prefix();
160
+ return safe(() => store.keys(), []).filter((k) => k.startsWith(p)).map((k) => k.slice(p.length));
161
+ },
162
+
163
+ forget(name) {
164
+ if (store && name) safe(() => store.remove(prefix() + name));
165
+ },
166
+ };
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgamesfun/solana",
3
- "version": "0.20.1",
3
+ "version": "0.24.0",
4
4
  "mcpName": "io.github.XGamesFun/solana-kit",
5
5
  "description": "One shared Solana program for game and community economies, its JS client, and the tools that let an AI agent design, check, simulate and launch an economy on it.",
6
6
  "license": "Apache-2.0",
package/release.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.20.1",
2
+ "version": "0.24.0",
3
3
  "programId": "9cwqM1TXytPnFZsLfmUyv2dg4uW6Ch9C6UJVPns7NFQ",
4
- "sbfBytes": 1457936,
5
- "sbfSha256": "358805a85fd32ba7b5b6bee9dd88e063bda6f854820d978265bca6683790aee7",
6
- "idlSha256": "0f75fb422c2e8eca80cfd4fd5ccc1cd8805df9cc12e981bfd0297810c772aca2",
7
- "clientSha256": "91537742722a9b31da8afd1f1a2af1bfeea389ad74981e740a41917f81ebecce"
4
+ "sbfBytes": 1727272,
5
+ "sbfSha256": "1bf01fbceb390c8888921df95f711d32e805554401e2c128e83eb16fcb11098c",
6
+ "idlSha256": "32dc0d5ed56fa7fb2ef8e90ef8d96d1d8bbe940b624d3d5b636933af20ae56b4",
7
+ "clientSha256": "f2a0f00386a08ecbd1fd939d53889d1a7a151997bf16684c641dd98a838b8d72"
8
8
  }
@@ -9,8 +9,8 @@ An economy is one JSON spec (`xsolana.json` in the project) on **loot**, a Solan
9
9
  writes or deploys a contract: the spec is checked, simulated and priced by the `xsolana_*` tools, and the project's
10
10
  owner puts it live by approving in their own wallet. It runs on **devnet** until the program is audited.
11
11
 
12
- The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.20.1 <guide|examples|check|simulate|cost|explain|publish|status> [xsolana.json]`,
13
- or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always with the exact version).
12
+ The tools are the `xsolana_*` MCP tools. Without MCP, the same from a terminal: `npx -y @xgamesfun/solana@0.24.0 <guide|examples|check|simulate|cost|explain|publish|status> [xsolana.json]`,
13
+ or `npx -y @xgamesfun/solana@0.24.0 tool <name> '<json>'` for any tool (always with the exact version).
14
14
 
15
15
  ## Rules (always)
16
16
 
@@ -33,7 +33,7 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
33
33
  economy is legal where it is offered is theirs. Never promise it is safe, and never give legal advice.
34
34
  7. **Take addresses from the tools, never from memory**: the program, its accounts, the IDL. Every answer names them.
35
35
  8. **Public data only in a spec**: names, prices, percents, public addresses. Leave out `id`, `index`, `shopBox`,
36
- `rewardsIndex` and `live`: they are written when it goes live.
36
+ and `live`: they are written when it goes live.
37
37
  9. **Compose before you say no.** Most ideas are several parts working together, not one part. Before calling
38
38
  anything a limit, break the idea into its pieces (what players pay, what they get, what is random, what the chain
39
39
  can't see, what is held and for how long, who is paid) and find the part for each (`references/sentence-to-spec.md`).
@@ -50,9 +50,9 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
50
50
  table.
51
51
 
52
52
  10. **People finish what follows; build the button, not a server.** Every step after a player's ask (settle a roll,
53
- draw a lottery, complete its tickets and pay its places, refund a draw the oracle never answered, claim a win, end
54
- an auction, take unbonded tokens out, run a sealed schedule, take a rental's NFT home) can be sent by anyone, and
55
- the person who gains from it sends it from the page (`app.claims()` lists them, `app.claim(c)` sends one). A
53
+ draw a lottery, pull its parts (each place, a refund, a share), refund a draw the oracle never answered, claim a
54
+ win, end an auction, run a sealed schedule, take a rental's NFT home) can be sent by the person who
55
+ gains from it, from the page (`app.claims()` lists them, `app.claim(c)` sends one). A
56
56
  grant's claim is the winner's alone: the winning wallet signs it, whatever proves it won; anyone may pay its fees,
57
57
  nobody claims for a player. loot needs no server to finish anything. Never make a key in a page or a server for
58
58
  the owner to fund: whoever signs pays.
@@ -74,13 +74,17 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
74
74
  sends each step while it runs (a draw, a payout, a contest's result) and what happens if nobody does:
75
75
  `references/after-launch.md`.
76
76
  Prizes: give a known prize directly on whatever route wins it (a grant, a lottery place, a contest, a mission, a
77
- recipe): one step, no oracle. Use a roll of a box only for chance. Every other shape (bundles, an NFT as the price,
78
- stashes, gates, no-loss lotteries, amounts by rank) is in `references/sentence-to-spec.md` and `xsolana_guide`.
77
+ recipe): one step, no oracle. Use a roll of a box only for chance. A pack of cards is one box with `slots` (five
78
+ draws an open, a guaranteed rare), opened several at once from the page; cards whose traits differ are a fresh
79
+ item drawing from `tables`. Every other shape (bundles, an NFT as the price, stashes, gates, no-loss lotteries,
80
+ amounts by rank) is in `references/sentence-to-spec.md` and `xsolana_guide`.
81
+ Then say what stays the project's job (`references/telling-the-person.md`): the files behind every item's link
82
+ (every value of a fresh item's `{attribute}`), a table's rent, and the collectible rent a pack's settle pays.
79
83
  6. **Iterate on what they say.** "Make legendaries 0.5%", "a bigger seed": change, check, simulate, explain again.
80
84
  7. **Save it**: `xsolana_save {"spec": …}` writes `xsolana.json`, only if it checks. A live file keeps its program numbers.
81
85
  8. **When they say "put it live"**: `xsolana_publish` lists every step and its cost and gives the command. Tell them to
82
86
  run it themselves in the project folder, exactly as `xsolana_publish` gives it (it names the exact version:
83
- `npx -y @xgamesfun/solana@0.20.1 approve xsolana.json`; never drop the version). It opens a page on their computer where
87
+ `npx -y @xgamesfun/solana@0.24.0 approve xsolana.json`; never drop the version). It opens a page on their computer where
84
88
  they connect a devnet wallet and approve; what lands is written back into `xsolana.json`. You publish nothing.
85
89
  9. **After it is live**: `xsolana_status` shows each part on chain; read it before acting on what the person says
86
90
  happened. A later change is the same loop: save, publish, and the approval sends only what changed. What only the
@@ -90,7 +94,7 @@ or `npx -y @xgamesfun/solana@0.20.1 tool <name> '<json>'` for any tool (always w
90
94
  ## Building the app around it
91
95
 
92
96
  A game, a site or a bot calls the live economy with the player's own wallet, through the package's player module
93
- (`@xgamesfun/solana/play`: buy, open a box and collect the roll, pay, stake, compound and withdraw, craft, bid and end an
97
+ (`@xgamesfun/solana/play`: buy, open a box and collect the roll, pay, stake, compound and unstake, craft, bid and end an
94
98
  auction, list an NFT for sale or for rent and read who rents it, raffle an NFT, claim what a player is owed, run what
95
99
  is due, list what a player owns). Pages call `claims()` when they open and show each as a button.
96
100
  `references/apps.md` shows a whole page; `xsolana_client` gives the program and the calls. Never invent account layouts,
@@ -100,6 +104,6 @@ For staking an NFT of another collection, show what custody means and get explic
100
104
  ## More
101
105
 
102
106
  - From a sentence to a spec, with a worked example: `references/sentence-to-spec.md`
103
- - Telling the person what they get: `references/telling-the-person.md`
107
+ - Telling the person what they get, and what stays the project's job: `references/telling-the-person.md`
104
108
  - Building the app for a live economy: `references/apps.md`
105
109
  - After it goes live (who sends each step, owner pages, results from a game, a results server): `references/after-launch.md`
@@ -8,32 +8,38 @@ winner nobody pays.
8
8
 
9
9
  | Step | Who can send it | If nobody does |
10
10
  | --- | --- | --- |
11
- | A box's roll and settle, a shop buy | the player, in their own purchase (`js/play` does both in one prompt) | anyone can settle a rolled order, a normal item too (the app's backend delivers it from the `OrderSettled` receipt of a finalized transaction whose `meta.err` is null, once per order: `loot.receipts(tx)` gives none for a transaction with an error, whose logs can still read as all-success; it reads the transaction again when its log is truncated); a roll that failed or never came refunds (`app.refund`) |
12
- | A lottery draw, completing its contributions, paying its places | **anyone**: the page (`app.claims()` lists each available step), or a sweep. Paying a winner with no prize account opens it in the same transaction (the sender pays its rent) | selling stops at draw time. A result must be followed by completion of its contributions and payment of every place before the next round. A round nobody draws waits, with no deadline. If the oracle never answers a draw (7 days after it was asked for), anyone can cancel and refund each recorded buyer. An empty round moves on with the next ticket. A one-time lottery ends after its round |
13
- | A schedule that is due (an emission, rent, a drip) | **anyone**: the page (`app.runSchedule`), a player, a sweep | it waits, and catches up one run at a time |
14
- | Ending an auction | **anyone**: usually the winner, from the page | it waits: whenever it is ended, the lot goes to the highest bidder |
15
- | A contest's entries back when it is off, or its winners' claims | **anyone** (each entry's money goes only to its player) | they wait; nothing is lost |
11
+ | A box's roll and settle, a shop buy | the player, in their own purchase (`js/play` does both in one prompt) | the player, a key the player named for the order (`settler`) or the project's owner or authority settle a rolled order, a normal item too (the app's backend delivers it from the `OrderSettled` receipt of a finalized transaction whose `meta.err` is null, once per order: `loot.receipts(tx)` gives none for a transaction with an error, whose logs can still read as all-success; a truncated log stays truncated when read again, so it then goes by the order's account); a roll that failed or never came refunds (`app.refund`) |
12
+ | A lottery draw, then each part pulled | **anyone**: the page (`app.claims()` lists each available step), or a sweep. The draw's ask fixes the round's money (what its places take of the pot, each pool's prize) and opens the next round at once. A pull pays one part into its owner's account, opened in the same transaction when it is not there (the sender pays its rent): an entry's own (a refund, a ticket back), its places' part of the pot or their prizes (a raffled NFT, an item, tokens, a roll), or a part owed in common (a share, what the pot gets back) | selling stops at draw time. A round nobody draws waits, with no deadline. Once drawn, every part waits in the round for its own pull, in any order, holding up nobody else; the pull that leaves nothing owed closes the round, and the rent of its record goes back to whoever fronted it (the lottery's `roundRent`, or its first buyer). An NFT that can't move is made a hold for its winner. If the oracle never answers a draw (7 days after it was first asked for), the next ask cancels it, and every buyer's refund is pulled; the page pulls the sender's own refund in the cancel's own transaction. A due round's draw rides in front of the next ticket purchase. An empty round moves on with the next ticket. A one-time lottery ends after its round |
13
+ | A schedule that is due (an emission, rent, a drip) | the project's owner or authority, or who it pays (a wallet, the treasury): `app.runSchedule` signed by one of them, or their sweep (a sealed burn: anyone) | **a run missed is missed**: one sent late pays once, for the latest run due, and the runs before it lapse, so keep a key of the project's sending it on time. Only a sealed schedule's fixed amount pays every run due (a backlog of years takes a few calls) |
14
+ | Ending an auction | **anyone**: usually the winner, from the page | it waits: whenever it is ended, the lot goes to the highest bidder. A seller whose account can't take the money then doesn't hold up the winner: the lot goes, the auction stays open owing it (`app.auctions()`: `delivered`, `proceedsOwed`), and the next end, anyone's, pays and closes it |
15
+ | Catching a farm up (`walk_farm`), when it has passed more of its seats' end times than a move can walk | **anyone**, for free: `app.claims()` lists it, `app.walkFarm(key)` sends it in parts (`app.farmWalk(key)` says how many) | moves of that farm fail until it is walked; nothing is lost by the wait: what each seat earned by its end is exact |
16
+ | A contest's entries back when it is off, or a winner's part of a contest with no prize or picks | **anyone** (each entry's money goes only to its player); a winner's first claim with a prize or picks: the **winner or the project's keys**, and past 30 days the project's keys may pick for them | they wait; nothing is lost |
16
17
  | Opening a contest | the **authority** (the owner's wallet by default) | nothing happens |
17
- | Naming its winner, calling it off | the **authority**, or **anyone** sending a verdict one of its `deciders` signed (a results server's key: `decideIx` with `verdict`, after the Ed25519 check of `verdictBytes`) | after `resultDue` it is off by the clock and everyone claims back; a crowdfund short of its `minPot` when entries close is off at once |
18
+ | Naming its winner, calling it off | the **authority**, or **anyone** sending a verdict one of its `deciders` signed (a results server's key: `decideIx` with `verdict`, after the Ed25519 check of `verdictBytes`); a winner only once entries close, a call-off any time | after `resultDue` it is off by the clock and everyone claims back; a crowdfund short of its `minPot` when entries close is off at once |
18
19
  | Moving tokens out of a pool, minting, a `decisions` payout, rewriting a collectible | the **authority** | nothing happens |
19
20
  | A grant claim | the **winner** only, from the page: the winning wallet signs every claim, whatever proves it (a voucher, the list, an open grant); anyone may pay its fees, nobody claims for it | the prize waits for them |
20
21
  | A prize given directly (a grant's, a lottery place's, a contest's, a mission's, a recipe's: an item, tokens, an NFT of a stash) | the winner's own claim (a grant's: the winner signs it), the place's payment, the winning entry's claim, the unstake or the craft: it is given in that transaction, the sender paying what it opens (a collectible's rent, the winner's token account) | a grant's claim or a craft of a prize it can't give is refused; a lottery's place, a contest's claim or a mission's end goes on without it (an NFT already given, an item sold out or switched off, a pool short pays what it holds) |
21
- | A prize that is a roll of a box (a grant's, a lottery's place, a contest's winners, an auction's mystery lot, a mission's chest) | the winner's own claim (a grant's), the place's payment or the auction's end makes the winner's order (its rent goes back to the winner at settle); then **anyone** asks the oracle for the roll (`loot.askRollIx`, its fee the asker's) and **anyone** settles it (`loot.settleIx`), which hands over what it rolled, a stash's NFT included; `app.claims()` lists them | it waits, and is never refunded while it waits; asked and never answered, anyone asks again. What the box gives that has run out (an empty stash, a sold-out item) can't be won: the roll lands on its other outcomes, and a fixed prize its pool runs short of pays what the pool holds |
22
+ | A prize that is a roll of a box (a grant's, a lottery's place, a contest's winners, an auction's mystery lot, a mission's chest) | the winner's own claim (a grant's), the place's payment or the auction's end makes the winner's order (its rent goes back to the winner at settle); then the **winner or the project's keys** ask the oracle for the roll (`loot.askRollIx`, its fee the asker's) and the winner, a key the winner named, or the project's keys settle it (`loot.settleIx`), which hands over what it rolled, a stash's NFT included; `app.claims()` lists them | it waits, and is never refunded while it waits; asked and never answered, they ask again. What the box gives that has run out (an empty stash, a sold-out item) can't be won: the roll lands on its other outcomes, and a fixed prize its pool runs short of pays what the pool holds. With nothing at all in stock, only the winner asks, and that ends the roll with nothing (an auction's mystery lot: the winning bid refunded); a roll whose answer finds nothing at all waits again, for a restock |
22
23
  | Putting more NFTs in a stash, or taking one out | the **owner** (or the authority), holding them: add them to the stash's `nfts` and publish again (only the new ones go in), or `loot.stashIx` with `out` to any wallet | a stash nobody refills just runs out |
23
24
 
24
25
  "Anyone" means the page can offer a button for it, and any player can press it. Build those buttons with `js/play`:
25
- `app.claims()` lists what this player can finish now (a draw, its completion or cancellation, a payout, a refund, an
26
+ `app.claims()` lists what this player can finish now (a draw, its cancellation, a pull, a payout, a refund, an
26
27
  auction to end), and `app.claim(c)` sends one. A **sweep** is a server that sends overdue public steps for the
27
28
  projects it watches. One may or may not be running for the person's project, so never rely on it: the page's buttons
28
- are what finish them. A sweep never needs anyone's key but its own fee payer.
29
-
30
- A lottery's ticket payments stay separate from the prize pot until the draw succeeds: cuts, referrals and burns run
31
- after it. The buyer's share recipients are fixed when they buy; completing a successful contribution sends its shares
32
- and its prize funding. Cancelling instead (only for a draw the oracle never answered) returns its entire ticket
33
- payment and record rent to the recorded buyer, with no ticket split or burn; network and oracle fees stay spent. Prizes and
34
- outside donations already in the prize pot stay there for the lottery; they are not ticket refunds. Each purchase is
35
- a record of its own whose rent goes back to its buyer once it is completed (a winning one once its places are paid),
36
- so a round has no limit on buyers.
29
+ are what finish them. A sweep needs only its own fee payer for draws, refunds and returns; to settle or run a schedule, it needs a project key or a key the player named.
30
+
31
+ A round of a lottery is a record of its own, opened by its first purchase (the SOL the lottery holds for it,
32
+ `roundRent`, put in at setup by default and sized from the lottery's expected rounds and entries, pays its header, its
33
+ token accounts and each entry's bytes; with too little, its first buyer fronts the header and accounts, about 0.004 SOL
34
+ and up, and each buyer their entry; all back when the round closes, to whoever paid), and it holds its ticket money in
35
+ accounts of its own until each part is pulled: shares in common and burns run after the draw, and the pot holds only
36
+ what nobody is owed. Each purchase adds an entry: funded by `roundRent`, the buyer pays only the fee and the price; a
37
+ buyer who pays its bytes themselves (about 0.0002 SOL) never gets them back (they go to the treasury when the round
38
+ closes). A share the
39
+ buyer chooses (a referrer) is paid as they buy. Cancelling (only for a draw the oracle never answered, or a round short of its least sale) owes every entry its
40
+ whole ticket payment back, with no split or burn; network and oracle fees stay spent. Prizes and outside donations
41
+ already in the pot stay there for the lottery; they are not ticket refunds. A round holds about 70,000 to 260,000
42
+ purchases, by how wide its entries are.
37
43
 
38
44
  Every account a payment goes to is opened by the transaction that pays it, and whoever sends it pays the rent: the
39
45
  treasury's or a payee's account at a settle, a winner's at a payout, an outbid bidder's at the next bid. Nothing waits
@@ -47,12 +53,14 @@ methods). What the project writes about one (a level, a use) goes in its Sheet (
47
53
  the authority with `collectible` `Set`, by a farm's growth, or by anyone proving the traits the project vouched for
48
54
  (`app.proveTraits`).
49
55
 
50
- A new mission is a new farm: add it to the spec and publish again; `runs` ends it that long after it goes live. Near the end of a farm that shares its pool, fund it at least its release span (its longest lock, a day at least) before the end and refresh any seat you suspect is dead a span before it: what is not released by the end, and what a seat gives back after it, stays in the pool, the project's. If rewards cannot move, unstaking can explicitly forfeit them.
56
+ A new mission is a new farm: add it to the spec and publish again; `runs` ends it that long after it goes live. Near the end of a farm that shares what lands, fund it at least its release span (its longest lock, a day at least) before the end: what is not released by the end, and what a seat forfeits after it, stays in its rewards vault, where the authority may take back what is free (`loot.moveTokensIx` with `from: { Farm: [id] }`), never what the farm owes its seats. No seat needs a refresh at its end. If rewards cannot move, unstaking can explicitly forfeit them.
51
57
 
52
- Changing a live box's roll (its outcomes, odds, `stockOdds`, `rollsOn`, `then`) fails every roll of it still waiting
53
- (`W-LIVE-ROLLS`): a purchase gets its payment back, but a roll won on another route (a grant's, a mission's chest, a
54
- lottery's place, an auction's lot) paid nothing in and refunds 0. Publish such a change once none waits, or make a new
55
- box. Its price, rise, fall, split, switch, window and gate change freely. A set's way out (the recipe that unbinds it)
58
+ Changing a live box's roll (its outcomes, odds, `stockOdds`, `whenOut`, `rollsOn`, `then`) fails a purchase still waiting for its
59
+ roll, and an auction's roll lot, each of which gets its payment back at once (`W-LIVE-ROLLS`). Any other roll waiting (a
60
+ grant's, a mission's chest, a lottery's place, a recipe's) holds no payment and rolls on the box as edited: nothing spent is
61
+ lost. A roll asked and left unanswered 7 days can be refunded when it holds a payment or an NFT (its refund names the
62
+ box, whose count of draws waiting it leaves: `app.refund` does). A box that stops selling when an outcome runs out (the
63
+ default) sells again once the project restocks it (more units, tokens into its pool, NFTs into its stash) or edits it. Its price, rise, fall, split, switch, window and gate change freely. A set's way out (the recipe that unbinds it)
56
64
  should take no `pay` line (`W-UNBIND-PAY`): a recipe with one waits while the project is paused or the platform
57
65
  suspends it, and the pieces stay locked in their farm meanwhile.
58
66
 
@@ -101,11 +109,13 @@ total, between two dates) are the most anything can ever pay out, whatever happe
101
109
  - **A random draw nobody can rig** (a free draw for members): a results server's computed draws (drand), or a lottery.
102
110
 
103
111
  Per wallet per day ("2 wins a day each") uses `limits.perWalletEach: { "count": 2, "every": "1d" }`;
104
- `perDay` separately counts all wallets together. A wallet can claim at most 256 distinct signed voucher rounds
105
- within one grant incarnation, in any order. Plan a new campaign when that bound would be exceeded; changing
106
- limits or voucher keys does not clear its history. Closing and recreating a grant starts a new campaign with new
107
- receipts and invalidates old vouchers. A list written in xsolana.json pays each wallet once per grant incarnation: editing it lets newly added wallets
108
- claim, never a wallet already paid (for repeated payouts, list the wallet again under a new `round`, or use vouchers). A results server must read the current grant incarnation when accepting
112
+ `perDay` separately counts all wallets together. A grant keeps every round it paid each wallet within one incarnation,
113
+ 16 bytes a round (from the grant's funds), and each wallet's count in 28: one voucher may name several rounds, each
114
+ paid once, and the same voucher sent again later pays what a limit held back. Changing limits or voucher keys does not
115
+ clear its history. Closing and recreating a grant starts a new campaign with new records and invalidates old vouchers;
116
+ anyone may then close the old records (`loot.closeShardIx`), their rent to the owner. A grant for more than about
117
+ 100,000 wallet-rounds names `wallets` (and `rounds`) so its records are kept in enough lists. A list written in xsolana.json pays each wallet once per grant incarnation: editing it lets newly added wallets
118
+ claim, never a wallet already paid (for repeated payouts, list the wallet again under a new `round`, or use vouchers). A list leaf names no grant or incarnation, so a grant closed and made again with the same list pays every listed round again: give a re-made grant a fresh list (new rounds); the approval page warns when a change does that. A results server must read the current grant incarnation when accepting
109
119
  a result and include it in the voucher it signs.
110
120
 
111
121
  ## Before you act on what the person says happened
@@ -26,7 +26,7 @@ const app = solanaApp({ spec, rpcUrl: 'https://api.devnet.solana.com', wallet: w
26
26
 
27
27
  const shop = await app.shop(); // [{ key, name, price, priceUnits, currency, active, left }]
28
28
  await app.buy('badge', { listing: shop.find((l) => l.key === 'badge') }); // the price shown is the most it pays
29
- const boxes = await app.boxes(); // each box's price and outcome chances
29
+ const boxes = await app.boxes(); // each box's price, outcome chances and whenOut (show it: stops selling, or draws among what is left)
30
30
  await app.openBox('chest', { listing: boxes.find((l) => l.key === 'chest') }); // pays, waits for the roll, settles
31
31
  await app.pay('entry'); // a payment of the spec
32
32
  const lotteries = await app.lotteries(); // [{ key, name, price, currency, status, round, sold, drawsAt, nftTicket, … }]
@@ -40,7 +40,7 @@ const recipes = await app.recipes(); // [{ key, name, active, version }]
40
40
  await app.craft('merge', [assetA, assetB], 1, { listing: recipes.find((r) => r.key === 'merge') }); // a changed recipe is refused
41
41
  await app.auctions(); // each auction: high, nextBid, leading, secondsLeft, open, over, waiting (SOL)
42
42
  await app.bid('comet', '0.6'); // the bid it tops goes back to its bidder in the same transaction
43
- await app.endAuction('comet'); // once it is over, anyone: the lot to the winner { winner, asset? }
43
+ await app.endAuction('comet'); // once it is over, anyone: the lot to the winner { winner, asset?, rolled? }
44
44
  await app.unwrap(); // SOL back from a bid that was topped, from wrapped SOL to plain SOL
45
45
  await app.claimGrant('welcome'); // a wallet on the grant's list (or { voucher } from your server)
46
46
  await app.claimWins({ server, token }); // every prize a results server vouched for or listed, claimed by the winner; [{ round, signature | error }]
@@ -49,7 +49,9 @@ const loan = await app.loanOffer(lender, 1); // a lender's offer: { amount, repa
49
49
  await app.borrow(lender, 1, asset, { acceptRisks: true }); // only once the player saw the risks (refused without it)
50
50
  for (const c of await app.claims()) await app.claim(c); // what this player can finish: a roll to collect, a refund,
51
51
  // a contest payout, a due draw, an auction to end
52
- await app.runSchedule('drip'); // a schedule that is due (anyone may run it); null when not yet
52
+ await app.runSchedule('drip'); // a schedule that is due (the project's keys or who it pays); null when not yet. A run missed is missed: only a sealed fixed amount pays every run due
53
+ await app.farmWalk('guild'); // { ends, needsWalk, parts }: end times the farm has passed; a move refuses (FarmNeedsWalk) past what it can walk
54
+ await app.walkFarm('guild'); // walks it in parts (anyone, free); claims() lists it when it is needed
53
55
  await app.collectibles(); // what the player owns: [{ asset, item, name, uri, attributes }] (`item`: its key)
54
56
  await app.balances(); // what they hold of each of the economy's tokens
55
57
  ```
@@ -66,12 +68,31 @@ await app.balances(); // what they hold of each of the econo
66
68
  Tell the person the one command that starts it, and that it serves on devnet.
67
69
  - **What a roll looks like**: a box of several outcomes is rolled by the oracle a few seconds after the open; `openBox`
68
70
  waits for it. Show the player that it is rolling.
71
+ - **A page that closes before the settle**: the SOL its settle key holds is not lost in the same browser (on another device, with nothing stored, it can't be found yet). Build the wallet with
72
+ `walletStandardSigner(wallet, account)`: with a wallet that signs messages, the page asks once a session for a
73
+ signature over a fixed text ("loot: the keys that settle your orders…"; it moves nothing), and every settle key comes
74
+ from it, so a reload makes it again. With one that signs no messages, the key is kept in the page's storage,
75
+ encrypted. Call `app.recover()` when the page opens (after the wallet connects): it settles what rolled, sends back
76
+ what a key holds for an order that is gone, and leaves one still rolling for later. Say so to the player in a line
77
+ ("Finishing an earlier purchase…").
78
+ - **Packs**: `app.openBox(key, { listing, count: 10 })` opens ten packs in one purchase: one wallet prompt, one oracle
79
+ answer, `maxPrice` a pack's. It returns `won` (every card) and `assets`; a pack too big for one transaction is
80
+ settled in parts, each its own transaction (`parts`: their signatures), sent by the throwaway key the purchase funds (`oneSignature`, the default),
81
+ so the player signs once. More packs than one oracle answer draws (or than keep an order's rent and fee within 0.01
82
+ SOL: `loot.opensAnOrder(box)`) are several orders in that one purchase (`orders`). A pack of fixed cards (one outcome, nothing random) is bought in one step: its cards come
83
+ in the purchase, no oracle and no settle (one too big for a transaction is opened instead).
84
+ - **A roll a route makes** (a craft, a bundle bought, a grant claimed, an auction won): the page asks for it in the
85
+ route's own transaction and settles it after with a throwaway key, so the player signs once. When the rolled box has
86
+ nothing in stock, nothing is sent and the call rejects with SoldOut: tell the player to try again later. A compressed card's id is not in `assets` (it is the tree's count when minted): read it
87
+ from `claims()` or the player's assets. Show each card as it lands.
69
88
  - **What an auction looks like**: show the high bid, the least next bid (`nextBid`) and a countdown (`secondsLeft`;
70
89
  a late bid moves the end, so read `auctions()` again after every bid). A player who was outbid has their bid back
71
90
  already, nothing to claim: in SOL it waits in their wrapped-SOL account (`waiting`), so say "You were outbid:
72
91
  0.5 SOL is back in your wallet" and offer `unwrap()` to turn it into plain SOL (any later purchase does it too).
73
92
  When `over` is true, show an "End the auction" button to everyone (the winner usually presses it; it mints or
74
- hands over the lot). There is no deadline: however late it is ended, the lot goes to the highest bidder.
93
+ hands over the lot). There is no deadline: however late it is ended, the lot goes to the highest bidder. An auction with
94
+ `delivered` has given its lot, and still owes its seller, whose account couldn't take the money (`proceedsOwed`): keep it
95
+ on the seller's page until an end (`app.endAuction`, anyone's) pays and closes it. Don't treat it as closed at its end.
75
96
  - **NFTs the project has no authority over**: a gate or odds that name a collection find the player's Core NFTs of it
76
97
  themselves; for a Token Metadata NFT pass `shown: { mint, owner }`, for a compressed one `shown: { compressed:
77
98
  assetId, tree }` after a `loot.proveLeafIx` in the same transaction. `app.stakeNft`, `app.unstakeNft`, `app.refresh`,
@@ -84,10 +105,13 @@ await app.balances(); // what they hold of each of the econo
84
105
  }])` from its NFTs' metadata and publishes each NFT's `attributes` and `proof` with the page; a player (or the page,
85
106
  before a stake) calls `app.proveTraits(nft, attributes, proof)` once per NFT, and its boosts and gates read them.
86
107
  - **Rolls on an NFT**: `app.openBox(boxKey, { on: asset })` for a box that rolls on one (an enchant). Leaving a
87
- mission that rolls, `app.unstakeNft` returns `{ signature, roll }`; a recipe that rolls, `app.craft` returns `roll`.
88
- `app.collect(roll)` asks the oracle when nobody has yet (`app.askRoll`), waits and settles it: show what it gave
89
- (`won`, each prize the roll set aside, and `order.fate`: the numbers moved and where the NFT went). A page should offer to collect any
90
- roll of the player's still waiting.
108
+ mission that rolls, `app.unstakeNft` returns `{ signature, roll, rolled }`; a recipe that rolls, `app.craft` returns
109
+ `roll` and `rolled`; a grant that gives a roll, `app.claimGrant` returns `{ signature, rolls }`; the winner ending an
110
+ auction of a roll, `app.endAuction` returns `rolled`. Each asks the oracle in the player's one transaction, and a
111
+ throwaway key that transaction funds settles it: one prompt. `rolled` (and each of `rolls`) is what the roll gave
112
+ (`won`, each prize it set aside, and `order.fate`: the numbers moved and where the NFT went). When the step and its
113
+ ask can't go together, the roll waits: `app.collect(roll)` asks for it, waits and settles it, and a page should offer
114
+ to collect any roll of the player's still waiting.
91
115
  - **What a staking page shows**: `app.staked(farmKey)` lists the player's seats in a farm, each with what it is owed
92
116
  now (`waiting`, in `rewards`), the program's own sum from a dry run of the seat (nothing signed or sent), and
93
117
  when its lock is over (`freeAt`). Show that; don't work out rewards from the rate yourself (a sharing farm splits
@@ -95,24 +119,27 @@ await app.balances(); // what they hold of each of the econo
95
119
  or a harvest.
96
120
  - **Getting out of a farm**: after the farm lock ends, `app.unstakeNft(farmKey, asset)` releases the collectible;
97
121
  `app.unstake(farmKey, amount)` returns tokens. If reward transfers are blocked, offer an explicit choice to give
98
- up rewards with `{ forfeit: true }` as the last argument. Remove a token seat's booster before withdrawing all
99
- its tokens. Forfeiting does not bypass the lock or the token's or NFT issuer's own controls: say which custody
122
+ up rewards with `{ forfeit: true }` as the last argument. Remove a token seat's booster before taking out all
123
+ its tokens. An early exit (a tier's `exitFee`) keeps the rewards the seat was credited; what is taken out leaves at
124
+ once (no unbonding wait). Forfeiting does not bypass the lock or the token's or NFT issuer's own controls: say which custody
100
125
  the farm uses and never promise an unconditional exit (`telling-the-person.md`, NFT custody).
101
126
  - **Farm token hooks and Core adapters**: `stake`, `unstake`, `stakeNft`, `unstakeNft`, and `harvest` accept
102
127
  `extra` account metas or `transfers` keyed by mint in their options. Static Token-2022 hooks and required memos
103
128
  resolve automatically. For dynamic hooks, provide every actual `{ source, destination, authority, amount }`
104
- transfer: reward pool to vault, then vault to player when the action pays; token principal too when it moves.
105
- Incoming transfer fees reduce reward credit before calculating the payout. Do not guess amounts from the gross
106
- pool balance. Without that plan the app fails before signing; refresh the plan if balances or fee terms change.
129
+ transfer: the farm's rewards vault to the player when the action pays (crediting moves no tokens); token principal
130
+ too when it moves. Do not guess amounts from the vault's balance. Without that plan the app fails before signing; refresh the plan if balances or fee terms change.
107
131
  Explicit `extra` replaces automatic resolution and must include all hook, memo and Core adapter accounts.
108
132
  For example, `app.unstakeNft('heroes', asset, { transfers: { [rewardsMint]: rewardTransfers } })`, or
109
133
  `app.unstakeNft('heroes', asset, { extra: adapterAndTokenAccounts })`. Harvest takes options third:
110
134
  `app.harvest('heroes', asset, { transfers: { [rewardsMint]: rewardTransfers } })`.
111
135
  A forfeit exit skips reward-mint reads and reward ATA creation; principal transfers still need their own mint.
112
- - **Lottery steps**: `claims()` lists a due draw, the completion of its recorded tickets, each place's prize, and,
113
- for a draw the oracle never answered, the cancel-and-refund (`after-launch.md` says how the money moves). Show each
114
- as a button and read the list again after each one: a drawn round's tickets must be completed before its winners
115
- collect.
136
+ - **Lottery steps**: `claims()` lists a due draw (`reveal`), what this player is owed (`pull`), paying everyone else
137
+ and the shares owed in common (`payout`), and for a draw the oracle never answered, asking again or, after its
138
+ timeout, the cancel-and-refund (`cancel-draw`; `after-launch.md` says how the money moves). A draw that cancels the
139
+ round (that one, or a `reveal` of a round short of its least sale) pulls the sender's own refund in the same
140
+ transaction. A ticket bought when the round's draw is due sends the draw first, in the same prompt, and buys into
141
+ the next round (the buyer pays the oracle's 0.0008 SOL for it). Show each as a button
142
+ and read the list again after each one. The parts are pulled in any order: nobody waits on anyone else.
116
143
  - **Gates**: a gated buy, ticket, stake, entry, bid or claim finds the player's collectible that passes the gate by
117
144
  itself ("You need a Guild Pass to buy this" when there is none). Use `collectibles()` and its `item` key to show
118
145
  what a player holds; never match NFTs by name. A list gate (`{ "list": [...] }`) takes the player's proof: the
@@ -120,8 +147,17 @@ await app.balances(); // what they hold of each of the econo
120
147
  power of two as the plan's root does), or takes it from a server that keeps a long list. A stake gate
121
148
  (`{ "staked": … }`) needs nothing from the page: js/play passes the player's seat and the farm, and says plainly when
122
149
  the stake is too small or its lock is over (show the farm's locked tiers next to the gate).
123
- - **A no-loss lottery** (`noLoss`): completing the draw sends every ticket back to its buyer (anyone may complete
124
- it, so say "your tickets come back after the draw"), and the winners then collect the pot.
150
+ - **A no-loss lottery** (`noLoss`): once the round is drawn every ticket is owed back to its buyer (a pull, which
151
+ anyone may send, so say "your tickets come back after the draw"), and the winners then collect the pot.
152
+ - **A lottery round** finishes in a few steps whatever its size: `app.claims()` lists the draw (`reveal`, whose ask
153
+ fixes the round's money and opens the next round at once), what this player is owed (`pull`: a refund, a place's
154
+ part of the pot, its prize; a raffled NFT; a roll asked for in the same transaction and settled in the same prompt)
155
+ and paying everyone else and the parts owed in common (`payout`); `app.claim(c)` sends one. The last pull closes
156
+ the round.
157
+ - **Repaying a loan** (the player's own, or a friend's): `app.loan(asset, due)` reads it; show its `repay` (base units
158
+ of its `mint`), then `app.repay(asset, due, { repay })` pays exactly that, in the payer's one prompt (a loan whose
159
+ debt changed since is refused); a throwaway key that transaction funds sends the NFT home to its borrower, a pNFT's
160
+ or a compressed NFT's too (one prompt). A lender sees an overdue unpaid loan in `claims()` (`release`, to them).
125
161
  - **A grant that pays by rank**: a voucher with an `amount` (base units of the grant's token) pays that much, up to
126
162
  the grant's; `app.claimGrant(key, { voucher })` passes it, and `{ proof, round, personHash, amount }` for a list.
127
163
  - **The price the player saw is the most they pay**: `app.buy` and `app.openBox` need the listing `shop()` or