@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
@@ -1,17 +1,21 @@
1
- // Reading the loot program's accounts and receipts. Every account starts with a one-byte tag (1 to 23); the IDL
2
- // describes most of them, and the ones it doesn't (a receipt, a sheet, a stash, …) are read here by hand. Values come back in the
1
+ // Reading the loot program's accounts and receipts. Every account starts with a one-byte tag (1 to 29); the IDL
2
+ // describes most of them, and the ones it doesn't (a list of records, a lottery round, a sheet, a stash, …) are read
3
+ // here by hand. Values come back in the
3
4
  // IDL's shapes (chain/borsh.js).
4
- import { encodeBase58, NO_ADDRESS } from '../chain/base58.js';
5
- import { decodeAccount, decodeEvents } from '../chain/borsh.js';
6
- import { compareBytes, fromHex, readUintLE, toHex } from '../chain/bytes.js';
5
+ import { addressBytes, encodeBase58, NO_ADDRESS } from '../chain/base58.js';
6
+ import { decodeAccount, decodeBorsh, decodeEvents } from '../chain/borsh.js';
7
+ import { compareBytes, concat, readUintLE, toHex, uintLE } from '../chain/bytes.js';
7
8
  import { sha256 } from '../chain/sha256.js';
8
9
  import { SYSTEM_PROGRAM } from '../chain/pda.js';
9
- import { LOOT_PROGRAM } from './pda.js';
10
+ import { LOOT_PROGRAM, pda } from './pda.js';
10
11
  import IDL from './idl/loot.js';
11
- import { MAX_RECEIPT_ROUNDS, RECEIPT_BASE_BYTES, RECEIPT_ROUND_BYTES } from './sizes.js';
12
- export { MAX_RECEIPT_ROUNDS } from './sizes.js';
13
12
 
14
- /** Account tags: each account's first byte, as the program writes it. */
13
+ /** The bytes of one time in a farm's record of ends (`decodeEnds`): rent its writer pays, not refunded when a seat leaves. */
14
+ export const END_BYTES = 44;
15
+ /** The bytes of a farm's record of ends with no time in it, its owner's rent at `open_farm`. */
16
+ export const ENDS_HEAD_BYTES = 7;
17
+
18
+ /** Account tags: each account's first byte, as the program writes it; every list of records (`decodeList`) is 26. */
15
19
  export const TAGS = Object.freeze({
16
20
  Platform: 1,
17
21
  Project: 2,
@@ -23,19 +27,23 @@ export const TAGS = Object.freeze({
23
27
  Recipe: 8,
24
28
  Hold: 9,
25
29
  Contest: 10,
26
- Entry: 11,
27
30
  Sheet: 12,
28
31
  Traits: 13,
29
- Grant: 14,
30
- Receipt: 15,
31
- LotteryContribution: 16,
32
+ LotteryRound: 16,
32
33
  Farm: 17,
33
34
  Seat: 18,
34
35
  Proven: 19,
35
36
  Auction: 20,
36
- Stash: 21,
37
37
  Offer: 22,
38
38
  Listing: 23,
39
+ Profile: 24,
40
+ Grant: 27,
41
+ // A table, and a stash: a table of NFTs (21 was a stash's own before 0.22).
42
+ Table: 25,
43
+ // A list of records: a wallet's counts, a grant's claims or rounds, a contest's entries.
44
+ List: 26,
45
+ // A farm's record of when its seats stop earning (`decodeEnds`).
46
+ Ends: 29,
39
47
  });
40
48
 
41
49
  const BY_TAG = Object.fromEntries(Object.entries(TAGS).map(([name, tag]) => [tag, name]));
@@ -43,43 +51,141 @@ const BY_TAG = Object.fromEntries(Object.entries(TAGS).map(([name, tag]) => [tag
43
51
  /** The kind of a loot account from its first byte, or null. */
44
52
  export const accountKind = (data) => (data?.length ? BY_TAG[data[0]] ?? null : null);
45
53
 
46
- /** A loot account of the named kind (one the IDL describes). Throws when the data is not one. */
47
- export const decode = (name, data) => decodeAccount(IDL, name, data);
54
+ /**
55
+ * The records the program writes by hand, as IDL types: a lottery round's header and what it keeps (`LotteryRound`,
56
+ * tag 16), and a contest's entry. The client reads them with the same Borsh codec as any account.
57
+ */
58
+ const RECORDS = [
59
+ { name: 'Round', type: { kind: 'enum', variants: ['Open', 'Drawing', 'Drawn', 'Cancelled'].map((name) => ({ name })) } },
60
+ { name: 'TokenPrize', type: { kind: 'struct', fields: [{ name: 'run', type: 'u16' }, { name: 'mint', type: 'pubkey' }, { name: 'each', type: 'u64' }, { name: 'sent', type: 'u64' }, { name: 'arrived', type: 'u64' }, { name: 'minted', type: 'bool' }] } },
61
+ { name: 'Opened', type: { kind: 'struct', fields: [{ name: 'mint', type: 'pubkey' }, { name: 'by', type: 'pubkey' }] } },
62
+ {
63
+ name: 'LotteryRound',
64
+ type: {
65
+ kind: 'struct',
66
+ fields: [
67
+ ['project', 'pubkey'], ['lottery', 'pubkey'], ['id', 'u32'], ['incarnation', 'u64'], ['round', 'u32'], ['bump', 'u8'], ['opener', 'pubkey'], ['lent', 'u64'], ['head', 'u32'],
68
+ ['status', { defined: { name: 'Round' } }],
69
+ // The terms, as the lottery's revision had them at the round's first purchase; whether its entries keep what
70
+ // arrived (a payment mint with a transfer fee, or a share the buyer chooses, paid as they buy).
71
+ ['revision', 'u32'], ['terms', { defined: { name: 'LotteryTerms' } }], ['fee', 'bool'],
72
+ // The totals, split at each purchase: what each share paid in common takes, the entries that own a part of
73
+ // their own, and the parts owed now.
74
+ ['sold', 'u64'], ['to_pot', 'u64'], ['common', { vec: 'u64' }], ['owns', 'u32'], ['owed', 'u32'],
75
+ // The draw, and the money the ask fixed: the pot's (what the round owes the pot back: `pot_owed`).
76
+ ['asked_first', 'u64'], ['asked_slot', 'u64'], ['first_asked_at', 'i64'], ['asked_at', 'i64'], ['answer', { array: ['u8', 32] }], ['first', 'u64'],
77
+ ['pot_total', 'u64'], ['pot_sent', 'u64'], ['pot_arrived', 'u64'], ['pot_owed', 'u64'], ['paid', 'bytes'],
78
+ ['prizes', { vec: { defined: { name: 'TokenPrize' } } }], ['accounts', { vec: { defined: { name: 'Opened' } } }],
79
+ // The NFT of the lottery's own stash each place that holds or draws one got, in place order, and how many.
80
+ ['nfts', { vec: 'pubkey' }], ['assigned', 'u32'],
81
+ ].map(([name, type]) => ({ name, type })),
82
+ },
83
+ },
84
+ { name: 'ContestEntry', type: { kind: 'struct', fields: [['player', 'pubkey'], ['side', 'u16'], ['amount', 'u64'], ['bits', 'u8'], ['nft', 'pubkey'], ['place', 'u32'], ['taken', 'u32']].map(([name, type]) => ({ name, type })) } },
85
+ ];
86
+ const IDLX = { ...IDL, types: [...IDL.types, ...RECORDS], accounts: [...IDL.accounts, { name: 'LotteryRound', discriminator: [TAGS.LotteryRound] }] };
87
+
88
+ /** A loot account of the named kind (one the IDL describes, or a `LotteryRound`). Throws when the data is not one. */
89
+ export const decode = (name, data) => decodeAccount(IDLX, name, data);
48
90
 
49
91
  /** Any loot account: { kind, value }, or null for data that is no loot account. */
50
92
  export function decodeAny(data) {
51
93
  const kind = accountKind(data);
52
94
  if (!kind) return null;
53
- if (kind === 'Receipt') return { kind, value: decodeReceipt(data) };
95
+ if (kind === 'Profile') return { kind, value: decodeProfile(data) };
96
+ if (kind === 'List') return { kind, value: decodeList(data) };
97
+ if (kind === 'Ends') return { kind, value: decodeEnds(data) };
98
+ if (kind === 'LotteryRound') return { kind, value: decodeRound(data) };
54
99
  if (kind === 'Sheet') return { kind, value: decodeSheet(data) };
100
+ if (kind === 'Table') return data[37] === PAYLOAD.Nft ? { kind: 'Stash', value: decodeStash(data) } : { kind, value: decodeTable(data) };
55
101
  if (kind === 'Proven') return { kind, value: decodeProven(data) };
56
102
  if (kind === 'Traits') return { kind, value: decodeTraits(data) };
57
- if (kind === 'Stash') return { kind, value: decodeStash(data) };
58
103
  return { kind, value: decode(kind, data) };
59
104
  }
60
105
 
106
+ // ---------------------------------------------------------------- lists of records (M1)
107
+
61
108
  /**
62
- * A counter (a Receipt): a wallet's on a box, a lottery, a recipe or a grant, a person's or a key's on
63
- * a grant. { count (lifetime where tracked), window (the window `inWindow` counts), inWindow, rounds (the first 8
64
- * bytes of sha256 of each round a grant paid it, by voucher or list, in order), claimed(round) }. A round it holds
65
- * was paid once and can never be paid again.
109
+ * A list of records (tag 26, `list.rs`): { width, records } in their order: a wallet's counts
110
+ * (`["counts", project, wallet]`) and a grant's claims are limit records (28 bytes, `readLimit`), a grant's rounds 16
111
+ * bytes, a contest's entries 43 or 83 (`decodeEntries`).
66
112
  */
67
- export function decodeReceipt(data) {
68
- if (!(data instanceof Uint8Array) || data[0] !== TAGS.Receipt || data.length < RECEIPT_BASE_BYTES) throw new Error('Not a Receipt account');
69
- const n = Number(readUintLE(data, RECEIPT_BASE_BYTES - 4, 4));
70
- if (n > MAX_RECEIPT_ROUNDS) throw new Error('Receipt history exceeds the supported capacity');
71
- if (data.length !== RECEIPT_BASE_BYTES + RECEIPT_ROUND_BYTES * n) throw new Error('Receipt length does not match its rounds');
72
- const rounds = Array.from({ length: n }, (_, i) => data.slice(RECEIPT_BASE_BYTES + RECEIPT_ROUND_BYTES * i, RECEIPT_BASE_BYTES + RECEIPT_ROUND_BYTES * (i + 1)));
73
- if (rounds.some((round, i) => i > 0 && compareBytes(rounds[i - 1], round) >= 0)) throw new Error('Receipt rounds must be unique and sorted');
74
- const ids = new Set(rounds.map((r) => toHex(r)));
75
- const claimed = (round) => ids.has(toHex(sha256(typeof round === 'string' ? fromHex(round) : Uint8Array.from(round)).subarray(0, 8)));
76
- return {
77
- count: Number(readUintLE(data, 1, 4)),
78
- window: Number(readUintLE(data, 5, 4)),
79
- inWindow: Number(readUintLE(data, 9, 4)),
80
- rounds,
81
- claimed,
82
- };
113
+ export function decodeList(data) {
114
+ if (!(data instanceof Uint8Array) || data[0] !== TAGS.List || data.length < 7) throw new Error('Not a list account');
115
+ const width = Number(readUintLE(data, 1, 2));
116
+ const count = Number(readUintLE(data, 3, 4));
117
+ if (!width || data.length !== 7 + width * count) throw new Error('List account does not match its records');
118
+ return { width, records: Array.from({ length: count }, (_, i) => data.subarray(7 + width * i, 7 + width * (i + 1))) };
119
+ }
120
+
121
+ /**
122
+ * A farm's record of when its seats stop earning (`["ends", farm]`, tag 29; `Farm.keeps_ends`): its times in order, each
123
+ * `{ at, rewardPerWeight, weight, seats }`: the time (Unix seconds), `reward_per_weight` there once the farm has walked past
124
+ * it, the weight that stops earning then, and the seats that still need the record.
125
+ */
126
+ export function decodeEnds(data) {
127
+ if (!(data instanceof Uint8Array) || data[0] !== TAGS.Ends || data.length < 7) throw new Error('Not a record of ends');
128
+ const width = Number(readUintLE(data, 1, 2));
129
+ const count = Number(readUintLE(data, 3, 4));
130
+ if (width !== END_BYTES || data.length !== 7 + width * count) throw new Error('The record of ends does not match its records');
131
+ return Array.from({ length: count }, (_, i) => {
132
+ const at = 7 + width * i;
133
+ let time = 0n;
134
+ for (let b = 0; b < 8; b++) time = (time << 8n) | BigInt(data[at + b]);
135
+ return { at: time, rewardPerWeight: readUintLE(data, at + 8, 16), weight: readUintLE(data, at + 24, 16), seats: Number(readUintLE(data, at + 40, 4)) };
136
+ });
137
+ }
138
+
139
+ /** The first 16 bytes of the sha256 of `parts` (addresses, bytes or u64s): a record's key in a list. */
140
+ export const recordKey16 = (...parts) => sha256(concat(...parts.map((p) => (typeof p === 'string' ? addressBytes(p) : p instanceof Uint8Array ? p : uintLE(p, 8))))).subarray(0, 16);
141
+
142
+ /** The key of a wallet's count for a box, a recipe (incarnation 0) or a lottery (its incarnation), in its counts. */
143
+ export const countKey = (record, incarnation = 0) => recordKey16(record, BigInt(incarnation));
144
+
145
+ /** What the limit record at `key` counts in a decoded list (`decodeList`, or null for none): { count, window, inWindow }. */
146
+ export function readLimit(list, key) {
147
+ const record = list?.records.find((r) => compareBytes(r.subarray(0, 16), key) === 0);
148
+ const n = (i) => (record ? Number(readUintLE(record, 16 + 4 * i, 4)) : 0);
149
+ return { count: n(0), window: n(1), inWindow: n(2) };
150
+ }
151
+
152
+ /** A fetched list account, or null for none (or an empty System account someone sent SOL to, which the program opens
153
+ * as the list). Any other owner or layout is refused. */
154
+ export function readListAccount(account) {
155
+ if (account == null) return null;
156
+ if (account.owner === SYSTEM_PROGRAM && account.data instanceof Uint8Array && account.data.length === 0 && !account.executable) return null;
157
+ if (account.owner !== LOOT_PROGRAM || account.executable) throw new Error('The list has an invalid owner.');
158
+ return decodeList(account.data);
159
+ }
160
+
161
+ /**
162
+ * A contest's entries (`["entries", contest, incarnation, 0]`), in the list's order (by player): [{ player, side,
163
+ * amount (0 once paid or refunded; 1 while a contest of NFTs holds its NFT), bits (`CLAIMED`, `ENDED`), nft, place (how
164
+ * many entered its side before it), taken (the losers' NFTs a winner took) }]. A claim names an entry by its index here.
165
+ */
166
+ export function decodeEntries(data) {
167
+ const { width, records } = decodeList(data);
168
+ return records.map((r) => {
169
+ const full = new Uint8Array(83);
170
+ full.set(r.subarray(0, Math.min(width, 83)));
171
+ return decodeBorsh(IDLX, { defined: { name: 'ContestEntry' } }, full)[0];
172
+ });
173
+ }
174
+
175
+ /** A winner's first claim came (its prize given or forgone); a winner ended its picks early. */
176
+ export const CLAIMED = 1;
177
+ export const ENDED = 2;
178
+
179
+ /** How many losers' NFTs a winner of a contest of NFTs takes: the share fixed at the decision, one more for an early
180
+ * place on its side. */
181
+ export const shareOf = (contest, entry) => Number(contest.share) + (Number(entry.place) < Number(contest.extras) ? 1 : 0);
182
+
183
+ /** What a count allows `n` more of at `now` under `limit` ({ total, per_window, window }, 0 is no limit; a lottery's
184
+ * window is its round plus one, given as `window`). */
185
+ export function countAllows(count, limit, now, n = 1, window = Number(limit.window) ? Math.floor(Number(now) / Number(limit.window)) + 1 : 0) {
186
+ const total = (count?.count ?? 0) + n;
187
+ const inWindow = (count && count.window === window ? count.inWindow : 0) + n;
188
+ return (!Number(limit.total) || total <= Number(limit.total)) && (!Number(limit.per_window) || inWindow <= Number(limit.per_window));
83
189
  }
84
190
 
85
191
  /**
@@ -89,11 +195,26 @@ export function decodeReceipt(data) {
89
195
  export function decodeSheet(data) {
90
196
  if (data == null || data.length === 0) return null;
91
197
  if (!(data instanceof Uint8Array) || data[0] !== TAGS.Sheet || data.length < 69) throw new Error('Not a Sheet account');
198
+ const { attributes, head } = attributesAt(data, 'Sheet');
199
+ return { project: head(1), nft: head(33), attributes };
200
+ }
201
+
202
+ /** A player's profile (`["profile", project, wallet]`): { project, wallet, attributes }, what the project keeps about
203
+ * the wallet (its XP, level, rank). Its layout is a Sheet's, under its own tag. */
204
+ export function decodeProfile(data) {
205
+ if (data == null || data.length === 0) return null;
206
+ if (!(data instanceof Uint8Array) || data[0] !== TAGS.Profile || data.length < 69) throw new Error('Not a Profile account');
207
+ const { attributes, head } = attributesAt(data, 'Profile');
208
+ return { project: head(1), wallet: head(33), attributes };
209
+ }
210
+
211
+ /** The attributes after a Sheet's or a profile's two addresses, read with every length checked against the data. */
212
+ function attributesAt(data, name) {
92
213
  let at = 65;
93
214
  // Every length is checked against the data: a count or a text longer than what is there is a truncated account.
94
215
  const text = () => {
95
216
  const n = Number(readUintLE(data, at, 4));
96
- if (at + 4 + n > data.length) throw new Error('Sheet account is truncated');
217
+ if (at + 4 + n > data.length) throw new Error(`${name} account is truncated`);
97
218
  const value = new TextDecoder().decode(data.subarray(at + 4, at + 4 + n));
98
219
  at += 4 + n;
99
220
  return value;
@@ -101,19 +222,53 @@ export function decodeSheet(data) {
101
222
  const count = Number(readUintLE(data, at, 4));
102
223
  at += 4;
103
224
  // Each attribute takes 8 bytes at least (two empty texts).
104
- if (count * 8 > data.length - at) throw new Error('Sheet account is truncated');
225
+ if (count * 8 > data.length - at) throw new Error(`${name} account is truncated`);
105
226
  const attributes = Array.from({ length: count }, () => ({ key: text(), value: text() }));
106
- return { project: encodeBase58(data.subarray(1, 33)), nft: encodeBase58(data.subarray(33, 65)), attributes };
227
+ return { attributes, end: at, head: (from) => encodeBase58(data.subarray(from, from + 32)) };
228
+ }
229
+
230
+ /** What a table's rows carry (its payload byte). */
231
+ export const PAYLOAD = Object.freeze({ Number: 0, Label: 1, Nft: 2 });
232
+ const TABLE_HEAD = 51;
233
+
234
+ /** Where row `r` of a table of `len`-byte rows is: groups of 64 blocks of 64 rows, each led by its weight in stock. */
235
+ export function tableRowAt(len, r) {
236
+ const block = 8 + 64 * len;
237
+ const group = 8 + 64 * block;
238
+ return TABLE_HEAD + Math.floor(r / 4096) * group + 8 + (Math.floor(r / 64) % 64) * block + 8 + (r % 64) * len;
107
239
  }
108
240
 
109
- /** A stash (`["stash", project, id]`): { project, id, nfts }, the NFTs the project holds to give away, in no order. */
241
+ /**
242
+ * A table (`["table", project, id]`, `table.rs`): { project, id, payload ('Number' | 'Label'), sealed, count, total
243
+ * (the weight in stock), rows: [{ weight, left, label }] }. A row's `left` is `NO_LIMIT` (4294967295) for a stock that
244
+ * never runs out.
245
+ */
246
+ export function decodeTable(data) {
247
+ if (data == null || data.length === 0) return null;
248
+ if (!(data instanceof Uint8Array) || data[0] !== TAGS.Table || data.length < TABLE_HEAD) throw new Error('Not a Table account');
249
+ const payload = Object.keys(PAYLOAD).find((k) => PAYLOAD[k] === data[37]);
250
+ if (!payload || payload === 'Nft') throw new Error('Not a Table account');
251
+ const count = Number(readUintLE(data, 39, 4));
252
+ const head = { project: encodeBase58(data.subarray(1, 33)), id: Number(readUintLE(data, 33, 4)), payload, sealed: data[38] === 1, count, total: readUintLE(data, 43, 8) };
253
+ const len = payload === 'Label' ? 40 : 8;
254
+ if (count && tableRowAt(len, count - 1) + len > data.length) throw new Error('Table account is truncated');
255
+ const rows = Array.from({ length: count }, (_, r) => {
256
+ const at = tableRowAt(len, r);
257
+ const label = payload === 'Label' ? new TextDecoder().decode(data.subarray(at + 8, at + 40)).replace(/\0+$/, '') : '';
258
+ return { weight: Number(readUintLE(data, at, 4)), left: Number(readUintLE(data, at + 4, 4)), label };
259
+ });
260
+ return { ...head, rows };
261
+ }
262
+
263
+ /** A stash (`["stash", project, id]`, or a lottery's own): { project, id, nfts, weights }, the NFTs the project holds to
264
+ * give away and the weight each is drawn by, as a table of NFTs keeps them (a row: its weight, then the NFT). */
110
265
  export function decodeStash(data) {
111
266
  if (data == null || data.length === 0) return null;
112
- if (!(data instanceof Uint8Array) || data[0] !== TAGS.Stash || data.length < 41) throw new Error('Not a Stash account');
113
- const count = Number(readUintLE(data, 37, 4));
114
- if (41 + 32 * count > data.length) throw new Error('Stash account is truncated');
115
- const nfts = Array.from({ length: count }, (_, i) => encodeBase58(data.subarray(41 + 32 * i, 73 + 32 * i)));
116
- return { project: encodeBase58(data.subarray(1, 33)), id: Number(readUintLE(data, 33, 4)), nfts };
267
+ if (!(data instanceof Uint8Array) || data[0] !== TAGS.Table || data[37] !== PAYLOAD.Nft || data.length < TABLE_HEAD) throw new Error('Not a Stash account');
268
+ const count = Number(readUintLE(data, 39, 4));
269
+ if (count && tableRowAt(36, count - 1) + 36 > data.length) throw new Error('Stash account is truncated');
270
+ const rows = Array.from({ length: count }, (_, r) => tableRowAt(36, r));
271
+ return { project: encodeBase58(data.subarray(1, 33)), id: Number(readUintLE(data, 33, 4)), nfts: rows.map((at) => encodeBase58(data.subarray(at + 4, at + 36))), weights: rows.map((at) => Number(readUintLE(data, at, 4))) };
117
272
  }
118
273
 
119
274
  /**
@@ -180,92 +335,263 @@ export function decodeTokenMetadata(data) {
180
335
  return { mint, collection, programmable, ruleSet };
181
336
  }
182
337
 
183
- /** A fetched receipt, or null for a missing/prefunded empty System account. Other owners/layouts are refused. */
184
- export function readReceiptAccount(account) {
185
- if (account == null) return null;
186
- if (account.owner === SYSTEM_PROGRAM && account.data instanceof Uint8Array && account.data.length === 0 && !account.executable) return null;
187
- if (account.owner !== LOOT_PROGRAM || account.executable) throw new Error('The receipt has an invalid owner.');
188
- return decodeReceipt(account.data);
189
- }
338
+ // ---------------------------------------------------------------- a lottery's rounds (M3)
339
+ //
340
+ // A round is a record of its own (`pda.round`), opened by its first purchase: a header with a copy of the terms, the
341
+ // totals, what it still owes and the draw, then one entry a purchase. The ask (`draw_lottery`) fixes its money and moves
342
+ // the lottery on; after the answer, `pull` pays one part at a time, in any order, and the pull that leaves nothing owed
343
+ // closes it.
344
+
345
+ /** An entry's word: its tickets' end in the low 48 bits, its own part paid in the top bit. */
346
+ const END_BITS = (1n << 48n) - 1n;
347
+ export const OWN_PAID = 1n << 63n;
190
348
 
191
- /** What a counter allows `n` more of at `now` under `limit` ({ total, per_window, window }, 0 is no limit). */
192
- export function receiptAllows(receipt, limit, now, n = 1) {
193
- const window = Number(limit.window) ? Math.floor(Number(now) / Number(limit.window)) + 1 : 0;
194
- const count = (receipt?.count ?? 0) + n;
195
- const inWindow = (receipt && receipt.window === window ? receipt.inWindow : 0) + n;
196
- return (!Number(limit.total) || count <= Number(limit.total)) && (!Number(limit.per_window) || inWindow <= Number(limit.per_window));
349
+ /** The mint a lottery's tickets are paid in (`terms`: a Lottery's or a round's): its ticket mint, burned, or its pot's. */
350
+ export const paymentMint = (terms) => (ticketsBurn(terms) ? terms.ticket_mint : terms.mint);
351
+ /** Whether a lottery's tickets are burned: they are paid in a ticket mint of its own. */
352
+ export const ticketsBurn = (terms) => Boolean(terms.ticket_mint) && terms.ticket_mint !== NO_ADDRESS;
353
+
354
+ /** A share the buyer chooses (a referrer, a collectible's holder): paid out of the price as they buy, from their own account. */
355
+ export const chosenAtPurchase = (share) => Boolean(share.to?.Chosen);
356
+
357
+ /** The bytes of one entry of a round: buyer, word, what arrived (`fee`: a fee mint, or a chosen share), the NFT ticket. */
358
+ export const entryWidth = (fee, nft) => 40 + (fee ? 8 : 0) + (nft ? 32 : 0);
359
+
360
+ /**
361
+ * A lottery round (`["round", lottery, incarnation, round]`): its header (`status` 'Open' | 'Drawing' | 'Drawn' |
362
+ * 'Cancelled', the terms it copied, `sold`, `owed`, the draw and the money the ask fixed, `nfts` the NFT of the
363
+ * lottery's own stash each such place got), and `entries`, one a purchase: { buyer, start, end (its tickets), paid (its
364
+ * own part's bit), amount (what reached the round), nft (its NFT ticket) }.
365
+ */
366
+ export function decodeRound(data) {
367
+ const r = decode('LotteryRound', data);
368
+ const at = Number(r.head);
369
+ const width = Number(readUintLE(data, at, 2));
370
+ const count = Number(readUintLE(data, at + 2, 4));
371
+ if (width !== entryWidth(r.fee, r.terms.nft_ticket != null) || data.length !== at + 6 + width * count) throw new Error('LotteryRound entries do not match its header');
372
+ let start = 0n;
373
+ const entries = Array.from({ length: count }, (_, i) => {
374
+ const e = data.subarray(at + 6 + width * i, at + 6 + width * (i + 1));
375
+ const word = readUintLE(e, 32, 8);
376
+ const end = word & END_BITS;
377
+ let k = 40;
378
+ const take = (n) => e.subarray((k += n) - n, k);
379
+ const amount = r.fee ? readUintLE(take(8), 0, 8) : (end - start) * BigInt(r.terms.price);
380
+ const entry = { buyer: encodeBase58(e.subarray(0, 32)), start, end, paid: word & ~END_BITS, amount, nft: r.terms.nft_ticket ? encodeBase58(take(32)) : NO_ADDRESS };
381
+ start = end;
382
+ return entry;
383
+ });
384
+ return { ...r, entries };
197
385
  }
198
386
 
199
- // ---------------------------------------------------------------- a lottery's purchases
387
+ /** How many places a lottery's (or a round's) `places` are, every run counted (`Place.times`). */
388
+ export const placeCount = (places) => places.reduce((n, p) => n + Number(p.times ?? 1), 0);
200
389
 
201
- /** The contribution among `contributions` ([{ address, value }], decoded LotteryContributions) that holds `ticket`. */
202
- export const contributionOf = (contributions, ticket) =>
203
- contributions.find(({ value }) => BigInt(value.first_ticket) <= BigInt(ticket) && BigInt(ticket) < BigInt(value.end)) ?? null;
390
+ /** The places of a round that have a winner: every place, but no more than tickets. */
391
+ export const roundWinners = (r) => Math.min(placeCount(r.terms.places), Number(r.sold));
204
392
 
205
- /** Whether a lottery's place wins something: a share of the pot, a roll, or both (as the program counts a place set). */
206
- export const placeSet = (place) => Number(place.bps) > 0 || place.prize != null;
393
+ /** The ticket that wins `place` of a drawn round: even steps around the list from first place's ticket. */
394
+ export function ticketOfPlace(r, place) {
395
+ const sold = BigInt(r.sold);
396
+ const ahead = BigInt(place) * (sold / BigInt(roundWinners(r) || 1));
397
+ const toEnd = sold - BigInt(r.first);
398
+ return ahead < toEnd ? BigInt(r.first) + ahead : ahead - toEnd;
399
+ }
207
400
 
208
- /** The places of a lottery that have a winner this round: as many as are set, but no more than tickets. */
209
- export function lotteryWinners(lottery) {
210
- const set = lottery.places.findIndex((p) => !placeSet(p));
211
- return Math.min(set === -1 ? lottery.places.length : set, Number(lottery.sold));
401
+ /** The run of places that holds `place`, and its first place. */
402
+ export function runOf(r, place) {
403
+ let first = 0;
404
+ for (let run = 0; run < r.terms.places.length; run++) {
405
+ if (place < first + Number(r.terms.places[run].times)) return [run, first];
406
+ first += Number(r.terms.places[run].times);
407
+ }
408
+ return [r.terms.places.length, first];
212
409
  }
213
410
 
214
- /** The ticket that wins `place` of a drawn lottery: even steps around the list from first place's ticket. */
215
- export function ticketOfPlace(lottery, place) {
216
- const sold = BigInt(lottery.sold);
217
- const winners = BigInt(lotteryWinners(lottery) || 1);
218
- const ahead = BigInt(place) * (sold / winners);
219
- const toEnd = sold - BigInt(lottery.first_ticket);
220
- return ahead < toEnd ? BigInt(lottery.first_ticket) + ahead : ahead - toEnd;
411
+ /** The places of run `run` with a winner: [first, end). */
412
+ export function runPlaces(r, run) {
413
+ const first = placeCount(r.terms.places.slice(0, run));
414
+ const w = roundWinners(r);
415
+ return [Math.min(first, w), Math.min(first + Number(r.terms.places[run].times), w)];
221
416
  }
222
417
 
223
- /** The places of a drawn lottery whose winning ticket is in `first..end` (a contribution's), in order. */
224
- export function placesWon(lottery, first, end) {
225
- const won = [];
226
- for (let place = 0; place < lotteryWinners(lottery); place++) {
227
- const ticket = ticketOfPlace(lottery, place);
228
- if (BigInt(first) <= ticket && ticket < BigInt(end)) won.push(place);
418
+ /** The places of run `run` whose ticket is in `start..end` (an entry's), in order. */
419
+ export function placesIn(r, run, start, end) {
420
+ const [from, to] = runPlaces(r, run);
421
+ const out = [];
422
+ if (from >= to || BigInt(start) >= BigInt(end)) return out;
423
+ const sold = BigInt(r.sold);
424
+ const step = sold / BigInt(roundWinners(r));
425
+ const d = (BigInt(start) + sold - BigInt(r.first)) % sold;
426
+ const len = BigInt(end) - BigInt(start);
427
+ for (const [a, b] of [[d, d + len < sold ? d + len : sold], [0n, d + len > sold ? d + len - sold : 0n]]) {
428
+ let p = Number((a + step - 1n) / step);
429
+ for (p = Math.max(p, from); p < to && BigInt(p) * step < b; p++) out.push(p);
229
430
  }
230
- return won;
431
+ return out.sort((x, y) => x - y);
432
+ }
433
+
434
+ /** What one place of a pass-through amount takes: `clamp(sent − before, 0, amount) × arrived / sent` (none of nothing sent). */
435
+ const through = (sent, arrived, before, amount) => {
436
+ if (sent === 0n) return 0n;
437
+ const part = sent > before ? (sent - before < amount ? sent - before : amount) : 0n;
438
+ return (part * arrived) / sent;
439
+ };
440
+
441
+ /**
442
+ * What place `place` (counted from the first place of `runs`) takes of a pass-through amount, as the program splits it:
443
+ * runs of `[a, n]` (n places taking `a` each), `sent` what the places are owed and `arrived` what the round holds for
444
+ * them; the last place with a part takes what the others leave.
445
+ */
446
+ export function splitPart(runs, sent, arrived, place) {
447
+ [sent, arrived] = [BigInt(sent), BigInt(arrived)];
448
+ let [c, at, last, paid, before] = [0n, 0, null, 0n, 0n];
449
+ for (const [a, n] of runs) {
450
+ const m = a === 0n ? 0 : Math.min(Number((sent > c ? sent - c + a - 1n : 0n) / a), n);
451
+ if (m > 0) {
452
+ const full = through(sent, arrived, 0n, a);
453
+ [last, before] = [at + m - 1, paid + BigInt(m - 1) * full];
454
+ paid = before + through(sent, arrived, c + BigInt(m - 1) * a, a);
455
+ }
456
+ [c, at] = [c + a * BigInt(n), at + n];
457
+ }
458
+ if (last === place) return arrived > before ? arrived - before : 0n;
459
+ [c, at] = [0n, 0];
460
+ for (const [a, n] of runs) {
461
+ if (place < at + n) return through(sent, arrived, c + BigInt(place - at) * a, a);
462
+ [c, at] = [c + a * BigInt(n), at + n];
463
+ }
464
+ return 0n;
231
465
  }
232
466
 
233
- /** The places of a drawn round that have a winner and a prize, in order: what it gives before its money moves. */
234
- export const prizePlaces = (lottery) => [...Array(lotteryWinners(lottery)).keys()].filter((place) => lottery.places[place].prize != null);
467
+ /** A share in basis points of an amount, rounded down. */
468
+ const bpsOf = (amount, bps) => (BigInt(amount) * BigInt(bps)) / 10_000n;
469
+
470
+ /** Whether place `place`'s part is paid: its pot part's bit (every place's first), or with `prize` its prize's (after
471
+ * every place's pot bit). */
472
+ export const placePaid = (r, place, prize) => {
473
+ const at = place + (prize ? placeCount(r.terms.places) : 0);
474
+ return Boolean((r.paid[at >> 3] ?? 0) & (1 << (at & 7)));
475
+ };
476
+
477
+ /** Entry `i`'s places in run `run` not paid yet of its pot part or (`prize`) its token prize, lowest first, each with
478
+ * what it takes: [{ place, amount }]. A pull pays all of them. */
479
+ function unpaidPlaces(r, i, run, prize) {
480
+ const e = r.entries[i];
481
+ const k = prize ? r.prizes.findIndex((p) => Number(p.run) === run) : -1;
482
+ if (prize && k < 0) return [];
483
+ const runs = k >= 0
484
+ ? [[BigInt(r.prizes[k].each), runPlaces(r, run)[1] - runPlaces(r, run)[0]]]
485
+ : r.terms.places.map((p, j) => [bpsOf(r.pot_total, p.bps), runPlaces(r, j)[1] - runPlaces(r, j)[0]]);
486
+ const [sent, arrived, from] = k >= 0 ? [r.prizes[k].sent, r.prizes[k].arrived, runPlaces(r, run)[0]] : [r.pot_sent, r.pot_arrived, 0];
487
+ return placesIn(r, run, e.start, e.end).filter((p) => !placePaid(r, p, prize)).map((place) => ({ place, amount: splitPart(runs, sent, arrived, place - from) }));
488
+ }
489
+
490
+ /** What entry `i`'s places in run `run` take and are not paid yet, of its pot part or (`prize`) its token prize. */
491
+ export const placesOwed = (r, i, run, prize) => unpaidPlaces(r, i, run, prize).reduce((sum, p) => sum + p.amount, 0n);
492
+
493
+ /** How many of the round's owed parts a pull of `{ Pot: [run] }` or a token `{ Prize: [run] }` for entry `i` settles:
494
+ * its places there that take more than nothing (one pull pays them all). */
495
+ export const placesPulled = (r, i, run, prize) => unpaidPlaces(r, i, run, prize).filter((p) => p.amount > 0n).length;
235
496
 
236
- /** The places a contribution won whose prize is not given yet (`Lottery.given`): its next step gives them. */
237
- export function lotteryOwed(lottery, contribution) {
238
- if (lottery.status !== 'Drawn') return [];
239
- const given = Number(lottery.given ?? 0);
240
- const prizes = new Set(prizePlaces(lottery));
241
- return placesWon(lottery, contribution.first_ticket, contribution.end).filter((place) => prizes.has(place) && !(given & (1 << place)));
497
+ /** Whether run `run` of a round's places gives tokens (its prize is fixed at the ask, a `TokenPrize`). */
498
+ export const tokenRun = (r, run) => Boolean(r.terms.places[run]?.prize?.Tokens);
499
+
500
+ /** A place whose prize is an NFT of the lottery's own stash (`Held`, `Raffled`): assigned by the pulls, in place order. */
501
+ export const ownStash = (place) => Boolean(place.prize?.Held) || place.prize === 'Raffled' || place.prize?.Raffled !== undefined;
502
+
503
+ /** Entry `i`'s places in run `run` whose prize (not tokens) is not given yet, lowest first. */
504
+ export const prizePlacesOwed = (r, i, run) => {
505
+ const e = r.entries[i];
506
+ return placesIn(r, run, e.start, e.end).filter((p) => !placePaid(r, p, true));
507
+ };
508
+
509
+ /** Where in `r.nfts` the NFT of `place` is (a place of a run that holds or draws one of the lottery's own stash). */
510
+ export function nftSlot(r, place) {
511
+ let [k, first] = [0, 0];
512
+ for (const p of r.terms.places) {
513
+ if (place < first + Number(p.times)) return k + place - first;
514
+ if (ownStash(p)) k += Number(p.times);
515
+ first += Number(p.times);
516
+ }
517
+ return k;
242
518
  }
243
519
 
244
- /** Whether every place's prize of a drawn round is given, so its money may move (`PrizesFirst`). */
245
- export const prizesGiven = (lottery) => prizePlaces(lottery).every((place) => Number(lottery.given ?? 0) & (1 << place));
520
+ /** How many places of a round win an NFT of the lottery's own stash (every such place with a winner). */
521
+ export function stashPlacesWon(r) {
522
+ const w = roundWinners(r);
523
+ let [first, won] = [0, 0];
524
+ for (const p of r.terms.places) {
525
+ if (ownStash(p)) won += Math.max(0, Math.min(first + Number(p.times), w) - first);
526
+ first += Number(p.times);
527
+ }
528
+ return won;
529
+ }
246
530
 
247
- /** The lowest place of a drawn round whose prize is not given yet, or null: prizes go in place order (`PlacesInOrder`). */
248
- export const nextPrizePlace = (lottery) => prizePlaces(lottery).find((place) => !(Number(lottery.given ?? 0) & (1 << place))) ?? null;
531
+ /** Whether entry `i`'s places in run `run` (an NFT of the lottery's own stash) wait for their NFTs to be taken out
532
+ * (`LotteryRound::assign`): a pull with the stash alone does it, and `r.nfts` then names each. */
533
+ export function awaitsNfts(r, i, run) {
534
+ if (!ownStash(r.terms.places[run])) return false;
535
+ const places = prizePlacesOwed(r, i, run);
536
+ return places.some((p) => nftSlot(r, p) >= Number(r.assigned));
537
+ }
249
538
 
250
539
  /**
251
- * What a contribution's next `finish_lottery_contribution` does, or null for nothing yet:
252
- * 'prizes' give the prize of the next place it won, one place a call (a drawn round gives every prize before any money moves);
253
- * 'refund' its tickets back to its buyer (a cancelled round, or a lottery that returns them);
254
- * 'finish' its money through the shares into the pot, its tickets burned, or its NFT ticket spent (only once every
255
- * prize of the round is given);
256
- * 'pay' its places' shares of the pot, once every contribution of the round is finished.
540
+ * The parts entry `i` of a round is owed now, in the order a keeper pulls them (`pullIx`'s `part`): its own ('Own': a
541
+ * refund, a no-loss ticket's price, its NFT ticket back or where its price sends it), and with `places`, its places'
542
+ * parts of the pot ({ Pot: [run] }) and prizes ({ Prize: [run] }). Parts owed in common (a share, the pot's, a pool's
543
+ * prize back) are no entry's: `commonOwed`.
257
544
  */
258
- export function lotteryStep(lottery, contribution) {
259
- // Its prize step comes when the lowest place still owed across the round is one it won; until then it waits.
260
- // In a lottery that returns its tickets, a winner's ticket goes back first, then its prizes.
261
- const owed = contribution.done || !lottery.returns ? lotteryOwed(lottery, contribution) : [];
262
- if (owed.length) return owed[0] === nextPrizePlace(lottery) ? 'prizes' : null;
263
- if (contribution.done) return Number(lottery.pending) === 0 ? 'pay' : null;
264
- if (lottery.status === 'Cancelled' || (lottery.status === 'Drawn' && lottery.returns)) return 'refund';
265
- if (lottery.status === 'Drawn') return prizesGiven(lottery) ? 'finish' : null;
266
- return null;
545
+ export function partsOwed(r, i, { places = true } = {}) {
546
+ const e = r.entries[i];
547
+ const parts = [];
548
+ const drawn = r.status === 'Drawn';
549
+ const back = r.status === 'Cancelled' || (r.terms.returns && drawn);
550
+ const nft = e.nft !== NO_ADDRESS;
551
+ if ((e.amount > 0n || nft) && (back || (nft && drawn)) && !(e.paid & OWN_PAID)) parts.push('Own');
552
+ if (!places || !drawn) return parts;
553
+ for (let run = 0; run < r.terms.places.length; run++) {
554
+ const prize = r.terms.places[run].prize;
555
+ if (placesOwed(r, i, run, false) > 0n) parts.push({ Pot: [run] });
556
+ if (tokenRun(r, run) ? placesOwed(r, i, run, true) > 0n : prize != null && prizePlacesOwed(r, i, run).length > 0) parts.push({ Prize: [run] });
557
+ }
558
+ return parts;
559
+ }
560
+
561
+ /**
562
+ * The parts of a round owed in common, of no entry (`{ Common: [i] }`): each share paid in common once drawn (one that
563
+ * burns is burned at the close), then what the round owes the lottery's pot (`i` = the shares' count), then after a
564
+ * cancel each pool's token prize back to its pool (past it).
565
+ */
566
+ export function commonOwed(r) {
567
+ const n = r.terms.shares.length;
568
+ const out = [];
569
+ if (r.status === 'Drawn') r.terms.shares.forEach((s, i) => s.to !== 'Burn' && BigInt(r.common[i] ?? 0) > 0n && out.push({ Common: [i] }));
570
+ if ((r.status === 'Drawn' || r.status === 'Cancelled') && BigInt(r.pot_owed) > 0n) out.push({ Common: [n] });
571
+ if (r.status === 'Cancelled') r.prizes.forEach((p, k) => !p.minted && BigInt(p.arrived) > 0n && out.push({ Common: [n + 1 + k] }));
572
+ return out;
267
573
  }
268
574
 
575
+ /** The entry of a round holding `ticket`. */
576
+ export const entryOf = (r, ticket) => r.entries.findIndex((e) => e.start <= BigInt(ticket) && BigInt(ticket) < e.end);
577
+
578
+ /** Whether a round is done and owes nothing: the next pull closes it, given its close's accounts. */
579
+ export const roundFinished = (r) => (r.status === 'Drawn' || r.status === 'Cancelled') && Number(r.owed) === 0;
580
+
581
+ /** Whether a lottery's place wins something: a share of the pot, a prize, or both. */
582
+ export const placeSet = (place) => Number(place.bps) > 0 || place.prize != null;
583
+
584
+ /** The address of a lottery's current round's record (there or not). */
585
+ export const roundAddress = (lotteryAddress, lottery) => pda.round(lotteryAddress, lottery.incarnation, lottery.round);
586
+
587
+ /** Whether a lottery (decoded) drew once and has ended. */
588
+ export const lotteryEnded = (lottery) => Number(lottery.every) === 0 && Number(lottery.round) > 0;
589
+
590
+ /** The round a lottery's close, its write again and its own stash's moves name (`lottery::check_idle`): its current
591
+ * round's, or once a lottery that draws once has ended, round 0's (which keeps its stash's NFTs until its places took
592
+ * them out). */
593
+ export const idleRound = (lotteryAddress, lottery) => pda.round(lotteryAddress, lottery.incarnation, lotteryEnded(lottery) ? 0 : lottery.round);
594
+
269
595
  /**
270
596
  * Who rents a listed NFT at `now` (Unix seconds), from its decoded Listing (or null for none): the renter's wallet while
271
597
  * a rental runs, else null (a sale listing, or a rental that is over).
@@ -275,10 +601,15 @@ export function renterOf(listing, now) {
275
601
  return BigInt(now) < BigInt(listing.until) && listing.user !== SYSTEM_PROGRAM ? listing.user : null;
276
602
  }
277
603
 
604
+ /** Whether a schedule pays every run due when it is sent late (`schedule::catches_up`): only a sealed fixed amount. */
605
+ export const scheduleCatchesUp = (schedule) => Boolean(schedule.action?.from?.Vest && schedule.action?.amount?.Fixed);
606
+
278
607
  /**
279
- * How many runs of a schedule are due at `now` (Unix seconds, the chain's clock, never the computer's): each its own
280
- * `run_schedule`, several in one transaction to catch up; one too many fails the whole transaction. A sealed schedule
281
- * past its last run is due once whenever its vault holds something (`vaultBalance`), which goes where it always went.
608
+ * How many runs of a schedule are due at `now` (Unix seconds, the chain's clock, never the computer's). One `run_schedule`
609
+ * makes them all. A sealed schedule's fixed amount pays each of them (`scheduleCatchesUp`; up to its `upto`, the runs made
610
+ * after it: `schedule.runs` plus these, and a long backlog takes a few calls). Any other run pays once, for the latest
611
+ * run due, and those before it lapse. A sealed schedule past its last run is due once whenever its vault holds something
612
+ * (`vaultBalance`), which goes where it always went.
282
613
  */
283
614
  export function runsDue(schedule, now, vaultBalance = 0n) {
284
615
  const runs = Number(schedule.runs), max = Number(schedule.max_runs);
@@ -312,7 +643,8 @@ export const ERRORS = Object.freeze(Object.fromEntries(IDL.errors.map((e) => [e.
312
643
 
313
644
  /**
314
645
  * The loot error in a failed transaction's logs ("custom program error: 0x…", or Anchor's "Error Code: …" line), or
315
- * null. Only loot's own: another program the transaction calls (Bubblegum, the VRF program, …) has codes of its own
646
+ * null: { code, name, msg }, `msg` the IDL's sentence (the program's own log names the error only: its text is
647
+ * compiled out). Only loot's own: another program the transaction calls (Bubblegum, the VRF program, …) has codes of its own
316
648
  * that may be the same numbers, so an error is loot's only when loot is the program that logged it or failed with it.
317
649
  * The first program that fails is where the error came from; the programs that called it fail after it with the same
318
650
  * code.
@@ -339,7 +671,7 @@ export function lootError(logs) {
339
671
  // A line that names no program (a log line, or an error's own text) is the running program's, when it is known.
340
672
  if (running.length && running.at(-1) !== LOOT_PROGRAM) continue;
341
673
  const named = /Error Code: (\w+)\. Error Number: (\d+)\. Error Message: (.+?)\.?$/.exec(line);
342
- if (named) return { code: Number(named[2]), name: named[1], msg: named[3] };
674
+ if (named) return { code: Number(named[2]), name: named[1], msg: ERRORS[Number(named[2])]?.name === named[1] ? ERRORS[Number(named[2])].msg : named[3] };
343
675
  const m = /custom program error: 0x([0-9a-f]+)/i.exec(line);
344
676
  if (m && ERRORS[parseInt(m[1], 16)]) return { code: parseInt(m[1], 16), ...ERRORS[parseInt(m[1], 16)] };
345
677
  }