@ixo/oracle-runtime-workers 0.32.0 → 0.33.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.
@@ -56,6 +56,8 @@ const DAODAO_VOTES_FROM: Record<ChainCall['network'], number> = {
56
56
 
57
57
  /** Proposal modules read per DAO (single choice, multiple choice…). */
58
58
  const MODULES_PER_DAO = 3;
59
+ /** DAO DAO's single-choice proposal contract (the only kind cards act on). */
60
+ const SINGLE_CHOICE = 'dao_proposal_single';
59
61
  /** DAOs read per kind of membership when no DAO is named. */
60
62
  const DAOS_PER_MEMBERSHIP = 3;
61
63
 
@@ -85,6 +87,7 @@ const proposalNode = z.looseObject({
85
87
  expiration: z.unknown().optional(),
86
88
  totalPower: opt,
87
89
  votes: z.unknown().optional(),
90
+ allowRevoting: z.boolean().nullish(),
88
91
  daoVotesByProposalModuleAndProposalId: z
89
92
  .object({ nodes: z.array(voteNode) })
90
93
  .optional(),
@@ -92,6 +95,8 @@ const proposalNode = z.looseObject({
92
95
  const moduleNode = z.looseObject({
93
96
  address: str,
94
97
  prefix: opt,
98
+ status: opt,
99
+ moduleType: opt,
95
100
  daoProposalsByProposalModule: connection(proposalNode),
96
101
  });
97
102
  const coreNode = z.looseObject({
@@ -141,12 +146,24 @@ function rowsOf(
141
146
  for (const module of modules.nodes) {
142
147
  const proposals = module.daoProposalsByProposalModule;
143
148
  if (proposals.pageInfo?.hasNextPage) more = true;
149
+ // Cards work only on a single-choice list. On a list the group
150
+ // switched off, votes and closes still work, but a proposal with
151
+ // actions cannot be carried out (Auth Hub's `list_disabled`), and this
152
+ // index row does not say whether a proposal has actions, so `canRun` is
153
+ // left out there. Auth Hub checks the contracts again.
154
+ const multipleChoice =
155
+ !!module.moduleType && module.moduleType !== SINGLE_CHOICE;
156
+ const listDisabled = module.status === 'disabled';
144
157
  for (const proposal of proposals.nodes) {
145
158
  const id = String(proposal.id);
146
159
  const vote = proposal.daoVotesByProposalModuleAndProposalId?.nodes[0];
147
160
  const indexedFromStart =
148
161
  typeof proposal.startHeight === 'number' &&
149
162
  proposal.startHeight >= votesFrom;
163
+ const expiry = expirationOf(proposal.expiration, now);
164
+ // The index keeps `open` until a proposal is carried out or closed:
165
+ // once voting has ended it may have passed or not.
166
+ const ended = proposal.status === 'open' && expiry.votingEnded === true;
150
167
  rows.push({
151
168
  startHeight: proposal.startHeight ?? -1,
152
169
  id,
@@ -158,7 +175,23 @@ function rowsOf(
158
175
  proposalModule: idText(module.address),
159
176
  title: text(proposal.title),
160
177
  status: text(proposal.status, 40),
161
- ...expirationOf(proposal.expiration, now),
178
+ ...expiry,
179
+ allowRevoting:
180
+ typeof proposal.allowRevoting === 'boolean'
181
+ ? proposal.allowRevoting
182
+ : undefined,
183
+ canRun:
184
+ !multipleChoice &&
185
+ !listDisabled &&
186
+ (proposal.status === 'passed' || ended)
187
+ ? true
188
+ : undefined,
189
+ canClose:
190
+ !multipleChoice && (proposal.status === 'rejected' || ended)
191
+ ? true
192
+ : undefined,
193
+ multipleChoice: multipleChoice ? true : undefined,
194
+ listDisabled: listDisabled ? true : undefined,
162
195
  proposer: idText(proposal.proposer),
163
196
  tally: tallyOf(proposal.votes),
164
197
  totalPower: amountText(proposal.totalPower),
@@ -195,7 +228,7 @@ function coreFields(
195
228
  const votes = voter
196
229
  ? ` daoVotesByProposalModuleAndProposalId(filter: ${equalTo('voter', voter)}, first: 1) { nodes { vote power votedAt } }`
197
230
  : '';
198
- return `address name${described ? ' description' : ''} daoProposalModulesByDaoAddress(first: ${MODULES_PER_DAO}, orderBy: PRIMARY_KEY_ASC) { ${PAGE} nodes { address prefix daoProposalsByProposalModule(${filter}first: ${n}, orderBy: PRIMARY_KEY_DESC) { ${PAGE} nodes { id title status proposer startHeight expiration totalPower votes${votes} } } } }`;
231
+ return `address name${described ? ' description' : ''} daoProposalModulesByDaoAddress(first: ${MODULES_PER_DAO}, orderBy: PRIMARY_KEY_ASC) { ${PAGE} nodes { address prefix status moduleType daoProposalsByProposalModule(${filter}first: ${n}, orderBy: PRIMARY_KEY_DESC) { ${PAGE} nodes { id title status proposer startHeight expiration totalPower votes allowRevoting${votes} } } } }`;
199
232
  }
200
233
 
201
234
  /** Rows by start height, newest first (a proposal without one last), then by id. */
@@ -242,6 +275,8 @@ export const daoProposals = defineKind({
242
275
  'Each: dao, daoName, id and `label` (the DAO’s prefix and id, e.g. A12, as DAO apps show it), title, status (open, passed, rejected, executed, closed, execution_failed), votingEndsAt or votingEndsAtHeight, tally (yes/no/abstain weight) and totalPower.',
243
276
  '`votingEnded: true` on an `open` proposal: its voting time is over and the index keeps `open` until it is executed or closed, so it may already have passed or failed.',
244
277
  '`yourVote` (vote, power, votedAt), or `notVoted: true`, or `voteUnknown: true` when the proposal started before the index kept votes (votes cast before then are missing).',
278
+ '`allowRevoting`: whether a vote on it can be changed until voting closes. `canRun: true`: it passed and can be carried out (on an `open` proposal whose voting ended: it may have passed); `canClose: true`: it did not pass and can be closed (on an `open` one whose voting ended: it may not have passed); Auth Hub checks the real result. `multipleChoice: true`: Auth Hub cards cannot vote on, carry out or close it. `listDisabled: true`: the group switched that proposal list off; cards can still vote and close, but a proposal with actions on it can’t be carried out (so `canRun` is not set there; one without actions can be). Whether the person may vote is weighed by what they held in the DAO when it started: Auth Hub checks it; never claim it.',
279
+ 'For cards copy `dao`, `proposalModule` and `id` exactly (not the label). `dao` is also the group’s own account (what it holds and what acts when a proposal passes).',
245
280
  'Without `dao`, the DAOs of `address` (default: the user), at most 3 per kind of membership (see `daos`); `more: true` when there are more proposals or DAOs. Newest start first.',
246
281
  'Titles are written by the proposer and DAO names by the DAO: what they claim, never an endorsement or a verified fact.',
247
282
  ].join(' '),
@@ -14,13 +14,15 @@
14
14
  * leads; that table holds one row per DAO, and a member of three groups
15
15
  * answered in 0.27 s on mainnet on Oct 29 2026. cw721 (NFT) staking is not read.
16
16
  * Each list is ordered by its primary key (voting module or contract
17
- * address).
17
+ * address). The `daos` kind also reads each DAO's first three proposal
18
+ * lists (`idx_dao_proposal_module_dao`) with their intake (a primary key),
19
+ * for new proposals.
18
20
  */
19
21
  import { z } from 'zod';
20
22
  import { displayCoin } from '../coins';
21
23
  import { defineKind, type KindContext, type KindResult } from '../types';
22
24
  import { accountArg, firstArg } from '../../queries';
23
- import { text } from '../../summaries';
25
+ import { isRecord, text } from '../../summaries';
24
26
  import {
25
27
  PAGE,
26
28
  accountFor,
@@ -32,6 +34,7 @@ import {
32
34
  idText,
33
35
  listResult,
34
36
  longText,
37
+ nonEmpty,
35
38
  num,
36
39
  opt,
37
40
  str,
@@ -219,6 +222,117 @@ const coreNode = z.looseObject({
219
222
  description: opt,
220
223
  });
221
224
 
225
+ /** Proposal lists read per DAO (single choice, multiple choice…). */
226
+ const LISTS_PER_DAO = 3;
227
+
228
+ /**
229
+ * A DAO's proposal lists: what a new proposal needs (who may propose, the
230
+ * deposit, the voting time) and what cards can do with the list. The
231
+ * `config`, `proposalCreationPolicy` and `depositInfo` columns hold the
232
+ * contracts' own query answers (DAO DAO v2: `{ allow_revoting,
233
+ * only_members_execute, max_voting_period, … }`, `{ anyone: {} }` or
234
+ * `{ module: { addr } }`, `{ denom: { native } | { cw20 }, amount,
235
+ * refund_policy }`).
236
+ */
237
+ const LIST_FIELDS = `daoProposalModulesByDaoAddress(first: ${LISTS_PER_DAO}, orderBy: PRIMARY_KEY_ASC) { ${PAGE} nodes { address prefix status moduleType proposalCreationPolicy config daoPreProposeModuleByPreProposeModule { depositInfo openProposalSubmission } } }`;
238
+ const listNode = z.looseObject({
239
+ address: str,
240
+ prefix: opt,
241
+ status: opt,
242
+ moduleType: opt,
243
+ proposalCreationPolicy: z.unknown().optional(),
244
+ config: z.unknown().optional(),
245
+ daoPreProposeModuleByPreProposeModule: z
246
+ .looseObject({
247
+ depositInfo: z.unknown().optional(),
248
+ openProposalSubmission: z.boolean().nullish(),
249
+ })
250
+ .nullish(),
251
+ });
252
+ const coreWithLists = coreNode.extend({
253
+ daoProposalModulesByDaoAddress: connection(listNode),
254
+ });
255
+ type CoreWithLists = z.infer<typeof coreWithLists>;
256
+
257
+ /** DAO DAO's single-choice proposal contract (the only kind cards vote on). */
258
+ const SINGLE_CHOICE = 'dao_proposal_single';
259
+ const REFUND_POLICIES = ['always', 'only_passed', 'never'] as const;
260
+
261
+ /** cw-utils `Duration` as words: `{ time: s }` or `{ height: n }`. */
262
+ function durationText(value: unknown): string | undefined {
263
+ if (!isRecord(value)) return undefined;
264
+ const count = (n: number, unit: string) =>
265
+ `${n} ${unit}${n === 1 ? '' : 's'}`;
266
+ if (typeof value.time === 'number' && Number.isSafeInteger(value.time)) {
267
+ const s = value.time;
268
+ if (s > 0 && s % 86_400 === 0) return count(s / 86_400, 'day');
269
+ if (s > 0 && s % 3_600 === 0) return count(s / 3_600, 'hour');
270
+ if (s > 0 && s % 60 === 0) return count(s / 60, 'minute');
271
+ return count(s, 'second');
272
+ }
273
+ if (typeof value.height === 'number' && Number.isSafeInteger(value.height))
274
+ return count(value.height, 'block');
275
+ return undefined;
276
+ }
277
+
278
+ /**
279
+ * A proposal deposit (`CheckedDepositInfo`): a bank coin as shown, or a
280
+ * cw20 amount with `cardCannotPay` (cards pay bank coins only), each with
281
+ * its refund rule; undefined when there is none or it is unreadable.
282
+ */
283
+ async function depositOf(ctx: KindContext, value: unknown) {
284
+ if (!isRecord(value) || !isRecord(value.denom)) return undefined;
285
+ const refundPolicy = REFUND_POLICIES.find(
286
+ (policy) => policy === value.refund_policy,
287
+ );
288
+ if (typeof value.denom.native === 'string') {
289
+ const coin = chainCoin({ denom: value.denom.native, amount: value.amount });
290
+ const shown = coin ? await displayCoin(ctx, coin) : null;
291
+ return shown ? compact({ ...shown, refundPolicy }) : undefined;
292
+ }
293
+ const amountBaseUnits = amountText(value.amount);
294
+ return typeof value.denom.cw20 === 'string' && amountBaseUnits
295
+ ? compact({
296
+ token: idText(value.denom.cw20),
297
+ amountBaseUnits,
298
+ refundPolicy,
299
+ cardCannotPay: true,
300
+ })
301
+ : undefined;
302
+ }
303
+
304
+ /** One proposal list of a DAO as the `daos` answer shows it. */
305
+ async function proposalListOf(
306
+ ctx: KindContext,
307
+ list: z.infer<typeof listNode>,
308
+ ) {
309
+ const config = isRecord(list.config) ? list.config : {};
310
+ const policy = isRecord(list.proposalCreationPolicy)
311
+ ? list.proposalCreationPolicy
312
+ : {};
313
+ const intake = list.daoPreProposeModuleByPreProposeModule;
314
+ // `anyone`: proposals go straight to the list, from anyone, without a
315
+ // deposit; `module`: through the list's intake, which holds both rules.
316
+ const viaIntake = isRecord(policy.module);
317
+ return compact({
318
+ proposalModule: idText(list.address),
319
+ prefix: text(list.prefix, 20),
320
+ disabled: list.status === 'disabled' ? true : undefined,
321
+ multipleChoice:
322
+ list.moduleType && list.moduleType !== SINGLE_CHOICE ? true : undefined,
323
+ membersOnly:
324
+ viaIntake && intake?.openProposalSubmission === false ? true : undefined,
325
+ deposit: viaIntake ? await depositOf(ctx, intake?.depositInfo) : undefined,
326
+ votingTime: durationText(config.max_voting_period),
327
+ allowRevoting:
328
+ typeof config.allow_revoting === 'boolean'
329
+ ? config.allow_revoting
330
+ : undefined,
331
+ membersOnlyCarryOut:
332
+ config.only_members_execute === true ? true : undefined,
333
+ });
334
+ }
335
+
222
336
  /** What a DAO row shows of its core. */
223
337
  type CoreText = {
224
338
  address: string;
@@ -226,7 +340,11 @@ type CoreText = {
226
340
  description?: string | null;
227
341
  };
228
342
 
229
- async function daoOf(ctx: KindContext, entry: Membership<CoreText>) {
343
+ async function daoOf<C extends CoreText>(
344
+ ctx: KindContext,
345
+ entry: Membership<C>,
346
+ extra?: (core: C) => Promise<Record<string, unknown>>,
347
+ ) {
230
348
  const native =
231
349
  entry.membership === 'native-staked' && entry.nativeDenom
232
350
  ? chainCoin({ denom: entry.nativeDenom, amount: entry.weight })
@@ -255,31 +373,47 @@ async function daoOf(ctx: KindContext, entry: Membership<CoreText>) {
255
373
  votingPowerShare: shareOf(entry.weight, entry.totalWeight),
256
374
  votingModule: idText(entry.votingModule),
257
375
  moduleType: text(entry.moduleType, 80),
376
+ ...(extra ? await extra(entry.core) : {}),
258
377
  });
259
378
  }
260
379
 
261
380
  /**
262
381
  * The `daos` answer's data from the membership answer: the first `n`
263
- * DAOs, and `more` when the index holds further memberships.
382
+ * DAOs, and `more` when the index holds further memberships. `extra` adds
383
+ * fields read with each DAO core (the `daos` kind's proposal lists).
264
384
  */
265
385
  export async function daosData<C extends CoreText>(
266
386
  ctx: KindContext,
267
387
  answer: MembershipRows<C>,
268
388
  n: number,
389
+ extra?: (core: C) => Promise<Record<string, unknown>>,
269
390
  ): Promise<Record<string, unknown>> {
270
391
  const all = membershipsOf(answer);
271
392
  return compact({
272
- items: await Promise.all(all.slice(0, n).map((entry) => daoOf(ctx, entry))),
393
+ items: await Promise.all(
394
+ all.slice(0, n).map((entry) => daoOf(ctx, entry, extra)),
395
+ ),
273
396
  more: all.length > n || moreMemberships(answer) ? true : undefined,
274
397
  });
275
398
  }
276
399
 
277
- const answerSchema = membershipSchema(coreNode);
400
+ /** A DAO core's proposal lists, and `moreProposalLists` past those read. */
401
+ async function listsOf(ctx: KindContext, core: CoreWithLists) {
402
+ const lists = core.daoProposalModulesByDaoAddress;
403
+ return {
404
+ proposalLists: nonEmpty(
405
+ await Promise.all(lists.nodes.map((list) => proposalListOf(ctx, list))),
406
+ ),
407
+ moreProposalLists: lists.pageInfo?.hasNextPage ? true : undefined,
408
+ };
409
+ }
410
+
411
+ const answerSchema = membershipSchema(coreWithLists);
278
412
 
279
413
  export const daos = defineKind({
280
414
  name: 'daos',
281
415
  summary:
282
- 'DAOs an account (default: the user) is a member of, how (staked or group member) and its share of the voting power.',
416
+ 'DAOs an account (default: the user) is a member of, how, its share of the voting power, and where each takes new proposals.',
283
417
  explain: [
284
418
  'A DAO (DAO DAO on the IXO chain) decides by proposals and votes; its voting module sets who votes and with what weight.',
285
419
  '`membership`: `cw4-member` (a group member with a fixed `weight`), `cw20-staked` (voting power from cw20 tokens staked in the DAO, `staked.amountBaseUnits` of the `staked.token` contract) or `native-staked` (from staked bank tokens, e.g. IXO; `staked` is a bank coin, `amount` in display units beside `symbol`).',
@@ -287,6 +421,8 @@ export const daos = defineKind({
287
421
  'Each: dao (the DAO core contract address), name, description, membership, staked or weight, votingPowerShare, votingModule.',
288
422
  '`addedByGroupAdmin: true` (every cw4-member row): a cw4 group’s admin can add any address to the group without its consent, so membership alone does not mean the account joined, agreed or knows of the DAO.',
289
423
  '`nameIsSelfChosen: true`: the name and description are whatever the DAO set for itself, never a verified identity or an endorsement; say so before relying on them.',
424
+ '`dao` is also the group’s own account: what the group holds (chain_lookup balances with that address) and what acts when one of its proposals passes.',
425
+ '`proposalLists` (at most 3; `moreProposalLists: true` past them): where the DAO takes proposals, each `proposalModule` (its address), `prefix` (the letter before proposal numbers, A in A3), `votingTime`, `allowRevoting` (whether a vote can be changed until voting closes), `membersOnly: true` (only members may propose), `membersOnlyCarryOut: true` (only members may carry out a passed proposal), and `deposit` when proposing takes one: a bank coin (`amount` beside `symbol`) or a cw20 amount with `cardCannotPay: true`, with `refundPolicy` (`only_passed`: back only if it passes and is carried out, `always`: back when carried out or closed, `never`: not back). `multipleChoice: true`: Auth Hub cards cannot use that list. `disabled: true`: the group switched it off: no new proposals through it, and its proposals with actions can’t be carried out (votes and closes still work).',
290
426
  'NFT (cw721) staking is not indexed here. Ask `dao_proposals` for what the DAOs are voting on.',
291
427
  ].join(' '),
292
428
  source: 'blocksync',
@@ -300,7 +436,7 @@ export const daos = defineKind({
300
436
  const subfields = membershipFields(
301
437
  member.address,
302
438
  n,
303
- 'address name description',
439
+ `address name description ${LIST_FIELDS}`,
304
440
  );
305
441
  return {
306
442
  source: 'blocksync',
@@ -309,7 +445,7 @@ export const daos = defineKind({
309
445
  const parsed = answerSchema.safeParse(raw);
310
446
  if (!parsed.success) return { ok: false, ...unexpected };
311
447
  return listResult(
312
- await daosData(ctx, parsed.data, n),
448
+ await daosData(ctx, parsed.data, n, (core) => listsOf(ctx, core)),
313
449
  n,
314
450
  member.subject,
315
451
  );