@ixo/oracle-runtime-workers 0.29.1 → 0.31.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 (74) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/runtime-context.ts +3 -0
  15. package/src/delivery/plan.ts +3 -1
  16. package/src/do/contracts.ts +3 -0
  17. package/src/do/turn-attachments.ts +103 -0
  18. package/src/do/user-oracle-do.ts +142 -42
  19. package/src/hub-actions/client.ts +87 -7
  20. package/src/hub-actions/hub-text.ts +82 -0
  21. package/src/hub-actions/request.ts +7 -4
  22. package/src/hub-actions/store.ts +5 -1
  23. package/src/hub-actions/transfer.ts +23 -6
  24. package/src/index.ts +1 -0
  25. package/src/plugin-api/index.ts +1 -0
  26. package/src/plugin-api/types.ts +43 -2
  27. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  28. package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
  29. package/src/plugins/ixo-chain/balances.ts +141 -0
  30. package/src/plugins/ixo-chain/blocksync.ts +17 -8
  31. package/src/plugins/ixo-chain/denoms.ts +84 -36
  32. package/src/plugins/ixo-chain/http.ts +315 -70
  33. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  34. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  35. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  36. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  37. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  38. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  39. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  40. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  41. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  42. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  43. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  44. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  45. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  46. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  47. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  48. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  49. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  50. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  51. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  52. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  53. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  54. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  55. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  56. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  57. package/src/plugins/ixo-chain/lookup.ts +470 -0
  58. package/src/plugins/ixo-chain/overview.ts +1060 -0
  59. package/src/plugins/ixo-chain/queries.ts +170 -138
  60. package/src/plugins/ixo-chain/rest.ts +135 -54
  61. package/src/plugins/ixo-chain/summaries.ts +3 -2
  62. package/src/plugins/yoma/catalog.ts +780 -0
  63. package/src/plugins/yoma/clef.ts +98 -0
  64. package/src/plugins/yoma/drafts.ts +339 -0
  65. package/src/plugins/yoma/engine.ts +926 -0
  66. package/src/plugins/yoma/index.ts +13 -0
  67. package/src/plugins/yoma/judge.ts +358 -0
  68. package/src/plugins/yoma/permission.ts +62 -0
  69. package/src/plugins/yoma/reads.ts +195 -0
  70. package/src/plugins/yoma/submission.ts +534 -0
  71. package/src/plugins/yoma/text.ts +16 -0
  72. package/src/plugins/yoma/work.ts +190 -0
  73. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  74. package/src/sqlite/serialization.ts +2 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `liquid_stake_history`: an account's liquid staking events (x/liquidstake
3
+ * stake and unstake) as Blocksync indexed them.
4
+ *
5
+ * Index (ixo-blocksync main, `20260101000000000_v7-upgrade.sql`):
6
+ * `LiquidStakeTx_delegator_idx` ("delegator"); ordered by the primary key
7
+ * `id` (indexing order), newest first. `payload` is the event's attributes;
8
+ * since chain v7 its amounts are coins (`{ denom, amount }`), before it
9
+ * `"<amount><denom>"` strings: both are read.
10
+ */
11
+ import { z } from 'zod';
12
+ import {
13
+ COINS_NOTE,
14
+ displayCoin,
15
+ type ChainCoin,
16
+ type DisplayCoin,
17
+ } from '../coins';
18
+ import { defineKind, type KindContext, type KindResult } from '../types';
19
+ import { accountArg, firstArg } from '../../queries';
20
+ import { text } from '../../summaries';
21
+ import {
22
+ PAGE,
23
+ accountFor,
24
+ chainCoin,
25
+ compact,
26
+ connection,
27
+ equalTo,
28
+ idText,
29
+ listResult,
30
+ num,
31
+ opt,
32
+ str,
33
+ unexpected,
34
+ } from './shared';
35
+ import { isRecord } from '../../summaries';
36
+
37
+ const args = z.strictObject({
38
+ address: accountArg('Delegator'),
39
+ first: firstArg,
40
+ });
41
+
42
+ const FIELDS = 'kind poolId payload transactionHash height timestamp';
43
+
44
+ const node = z.looseObject({
45
+ kind: str,
46
+ poolId: opt,
47
+ payload: z.unknown().optional(),
48
+ transactionHash: opt,
49
+ height: num,
50
+ timestamp: opt,
51
+ });
52
+
53
+ /** What each event kind means, in a few words. */
54
+ const MEANING: Record<string, string> = {
55
+ stake: 'staked through the pool, receiving its liquid token',
56
+ unstake: "returned the pool's liquid token to unstake",
57
+ autocompound: 'the pool restaked its rewards',
58
+ rebalance: 'the pool moved stake between validators',
59
+ addValidator: 'a validator joined the pool',
60
+ };
61
+
62
+ /** The payload keys of each event's amounts and the names they get. */
63
+ const AMOUNTS: Record<string, Record<string, string>> = {
64
+ stake: { liquid_amount: 'staked', stk_ixo_minted_amount: 'received' },
65
+ unstake: {
66
+ unstake_amount: 'returned',
67
+ unbonding_amount: 'unbonding',
68
+ unbonded_amount: 'unbonded',
69
+ },
70
+ autocompound: {
71
+ total_amount: 'total',
72
+ fee_amount: 'fee',
73
+ redelegate_amount: 'restaked',
74
+ weighted_rewards_amount: 'weightedRewards',
75
+ },
76
+ };
77
+
78
+ const LEGACY_COIN = /^(\d{1,80})([a-zA-Z][a-zA-Z0-9/:._-]{2,127})$/;
79
+
80
+ /** A payload amount: a coin object, or the `"<amount><denom>"` string events carried before v7. */
81
+ function payloadCoin(value: unknown): ChainCoin | undefined {
82
+ if (typeof value === 'string') {
83
+ const match = LEGACY_COIN.exec(value);
84
+ return match ? chainCoin({ denom: match[2], amount: match[1] }) : undefined;
85
+ }
86
+ return chainCoin(value);
87
+ }
88
+
89
+ /** The event's bank coins by what each is (`staked`, `received`…). */
90
+ async function coinsOf(
91
+ ctx: KindContext,
92
+ kind: string,
93
+ payload: Record<string, unknown>,
94
+ ): Promise<Record<string, DisplayCoin> | undefined> {
95
+ const coins: Record<string, DisplayCoin> = {};
96
+ for (const [key, name] of Object.entries(AMOUNTS[kind] ?? {})) {
97
+ const coin = payloadCoin(payload[key]);
98
+ const shown = coin ? await displayCoin(ctx, coin) : null;
99
+ if (shown) coins[name] = shown;
100
+ }
101
+ return Object.keys(coins).length ? coins : undefined;
102
+ }
103
+
104
+ async function eventOf(ctx: KindContext, row: z.infer<typeof node>) {
105
+ const payload = isRecord(row.payload) ? row.payload : {};
106
+ return compact({
107
+ kind: text(row.kind, 40),
108
+ meaning: MEANING[row.kind],
109
+ poolId: idText(row.poolId),
110
+ coins: await coinsOf(ctx, row.kind, payload),
111
+ completionTime:
112
+ row.kind === 'unstake' ? idText(payload.completion_time) : undefined,
113
+ txHash: idText(row.transactionHash),
114
+ height:
115
+ row.height === null || row.height === undefined
116
+ ? undefined
117
+ : idText(String(row.height)),
118
+ time: idText(row.timestamp),
119
+ });
120
+ }
121
+
122
+ export const liquidStakeHistory = defineKind({
123
+ name: 'liquid_stake_history',
124
+ summary:
125
+ 'Liquid staking history of an account (default: the user): stakes and unstakes per pool, newest first.',
126
+ explain: [
127
+ 'Liquid staking stakes IXO through a pool that spreads it over validators and gives back the pool’s liquid token (e.g. PAY for the `pay` pool), which can be held or moved while the IXO stays staked.',
128
+ 'Each row’s bank coins are in `coins`, by what each is. A `stake` row: `coins.staked` (IXO in) and `coins.received` (liquid token out). An `unstake` row: `coins.returned` (liquid token handed back), `coins.unbonding` (IXO now unbonding, paid out at `completionTime`) and `coins.unbonded` (IXO paid at once). An `autocompound` row: `coins.total`, `coins.fee`, `coins.restaked`, `coins.weightedRewards`.',
129
+ COINS_NOTE,
130
+ 'Each row also has poolId, txHash, height and time. Ask `liquid_staking` for the pools and the account’s current liquid token balance.',
131
+ ].join(' '),
132
+ source: 'blocksync',
133
+ list: true,
134
+ args,
135
+ example: { first: 5 },
136
+ plan(input, ctx) {
137
+ const delegator = accountFor(input.address, ctx.identity, 'address');
138
+ if ('invalid' in delegator) return delegator;
139
+ const n = ctx.first;
140
+ return {
141
+ source: 'blocksync',
142
+ field: (alias) =>
143
+ `${alias}: liquidStakeTxes(filter: ${equalTo('delegator', delegator.address)}, first: ${n}, orderBy: ID_DESC) { ${PAGE} nodes { ${FIELDS} } }`,
144
+ async shape(raw): Promise<KindResult> {
145
+ const parsed = connection(node).safeParse(raw);
146
+ if (!parsed.success) return { ok: false, ...unexpected };
147
+ return listResult(
148
+ {
149
+ items: await Promise.all(
150
+ parsed.data.nodes.map((row) => eventOf(ctx, row)),
151
+ ),
152
+ more: parsed.data.pageInfo?.hasNextPage ? true : undefined,
153
+ },
154
+ n,
155
+ delegator.subject,
156
+ );
157
+ },
158
+ };
159
+ },
160
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `member_budgets`: an account's per-period spending budgets in team
3
+ * claim collections (x/claims `MemberBudget`).
4
+ *
5
+ * Index (ixo-blocksync main): `MemberBudget_memberAddress_idx`
6
+ * ("memberAddress") serves the account; the primary key ("collectionId",
7
+ * "memberAddress") serves a collection with it and gives the order
8
+ * (collection id as text).
9
+ */
10
+ import { z } from 'zod';
11
+ import { literal } from '../../queries';
12
+ import { COINS_NOTE, displayCoins, type ChainCoin } from '../coins';
13
+ import { defineKind, type KindContext, type KindResult } from '../types';
14
+ import { accountArg, firstArg } from '../../queries';
15
+ import {
16
+ PAGE,
17
+ accountFor,
18
+ chainCoins,
19
+ collectionIdArg,
20
+ compact,
21
+ connection,
22
+ cw20Amounts,
23
+ idText,
24
+ listResult,
25
+ num,
26
+ opt,
27
+ reached,
28
+ str,
29
+ unexpected,
30
+ } from './shared';
31
+
32
+ const args = z.strictObject({
33
+ address: accountArg('Team member'),
34
+ collectionId: collectionIdArg('Only this collection, e.g. "8250".'),
35
+ first: firstArg,
36
+ });
37
+
38
+ const FIELDS =
39
+ 'collectionId memberAddress periodNs periodSpendLimit periodSpent periodCw20SpendLimit periodCw20Spent periodResetAt updatedAt';
40
+
41
+ const node = z.looseObject({
42
+ collectionId: str,
43
+ memberAddress: str,
44
+ periodNs: num,
45
+ periodSpendLimit: z.unknown().optional(),
46
+ periodSpent: z.unknown().optional(),
47
+ periodCw20SpendLimit: z.unknown().optional(),
48
+ periodCw20Spent: z.unknown().optional(),
49
+ periodResetAt: opt,
50
+ updatedAt: opt,
51
+ });
52
+
53
+ const DIGITS = /^\d+$/;
54
+ const NS_PER_SECOND = 1_000_000_000n;
55
+
56
+ /** A period in nanoseconds as days, hours or seconds (the largest unit it divides into). */
57
+ function periodLabel(
58
+ value: string | number | null | undefined,
59
+ ): string | undefined {
60
+ if (value === null || value === undefined) return undefined;
61
+ const ns = String(value);
62
+ if (!DIGITS.test(ns)) return undefined;
63
+ const seconds = BigInt(ns) / NS_PER_SECOND;
64
+ if (seconds > 0n && seconds % 86_400n === 0n)
65
+ return `${seconds / 86_400n} days`;
66
+ if (seconds > 0n && seconds % 3_600n === 0n)
67
+ return `${seconds / 3_600n} hours`;
68
+ return `${seconds} seconds`;
69
+ }
70
+
71
+ /**
72
+ * Per limit denom, what is left this period: the limit less what counts
73
+ * as spent in it (never below zero).
74
+ */
75
+ function remainingOf(limit: ChainCoin[], spent: ChainCoin[]): ChainCoin[] {
76
+ return limit.map((coin) => {
77
+ const used = spent.find((entry) => entry.denom === coin.denom);
78
+ const left = BigInt(coin.amount) - (used ? BigInt(used.amount) : 0n);
79
+ return { denom: coin.denom, amount: String(left > 0n ? left : 0n) };
80
+ });
81
+ }
82
+
83
+ /**
84
+ * An empty limit lets the member commit none of that kind (x/claims
85
+ * checks an intent's coins against the limit less what was spent, and a
86
+ * denom or cw20 contract it does not list has nothing left); the chain
87
+ * refuses a budget with neither a native nor a cw20 limit.
88
+ */
89
+ const NONE_ALLOWED = 'none allowed';
90
+ /** What was spent this period, or `"none"`. */
91
+ const spentOrNone = <T>(list: T[]): T[] | 'none' =>
92
+ list.length ? list : 'none';
93
+
94
+ async function budgetOf(
95
+ ctx: KindContext,
96
+ row: z.infer<typeof node>,
97
+ now: number,
98
+ ) {
99
+ const limit = chainCoins(row.periodSpendLimit);
100
+ const cw20Limit = cw20Amounts(row.periodCw20SpendLimit);
101
+ // After the reset time nothing spent counts: the chain zeroes it at the
102
+ // member's next intent.
103
+ const resetDue = reached(row.periodResetAt, now) === true;
104
+ const spent = resetDue ? [] : chainCoins(row.periodSpent);
105
+ const cw20Spent = resetDue ? [] : cw20Amounts(row.periodCw20Spent);
106
+ const [spendLimit, spentShown, remaining] = await Promise.all([
107
+ displayCoins(ctx, limit),
108
+ displayCoins(ctx, spent),
109
+ displayCoins(ctx, remainingOf(limit, spent)),
110
+ ]);
111
+ const native = spendLimit.length > 0;
112
+ return compact({
113
+ collectionId: idText(row.collectionId),
114
+ member: idText(row.memberAddress),
115
+ period: periodLabel(row.periodNs),
116
+ spendLimit: native ? spendLimit : NONE_ALLOWED,
117
+ spent: spentOrNone(spentShown),
118
+ remaining: native ? remaining : undefined,
119
+ cw20SpendLimit: cw20Limit.length ? cw20Limit : NONE_ALLOWED,
120
+ cw20Spent: spentOrNone(cw20Spent),
121
+ resetAt: idText(row.periodResetAt),
122
+ resetDue: resetDue ? true : undefined,
123
+ updatedAt: idText(row.updatedAt),
124
+ });
125
+ }
126
+
127
+ export const memberBudgets = defineKind({
128
+ name: 'member_budgets',
129
+ summary:
130
+ 'Spending budgets an account (default: the user) has as a team member of claim collections.',
131
+ explain: [
132
+ 'A collection with member budgets is a team collection: every claim intent on it is made on behalf of a member, and each member may commit at most `spendLimit` of the collection’s payments per `period` (at least 24 hours).',
133
+ '`spendLimit` is bank coins (a denom it does not list may not be committed), or `"none allowed"`: no bank coin, only the cw20 tokens of `cw20SpendLimit` (likewise `"none allowed"` when no cw20 token may be committed).',
134
+ 'Committing an intent adds its amount to `spent` (`"none"` when nothing counts this period); a rejected, disputed or invalidated claim, or an intent that expires unused, gives it back within the same period.',
135
+ '`remaining` is the limit less what was spent this period. The period rolls over at `resetAt`, lazily: `resetDue: true` means that time has passed, so nothing spent counts any more and the chain zeroes it at the member’s next intent.',
136
+ COINS_NOTE,
137
+ '`cw20SpendLimit`/`cw20Spent` are base units per cw20 token contract. cw1155 payments are not budgeted.',
138
+ 'Ordered by collection id as text. Empty when the account is not a member of any team collection.',
139
+ ].join(' '),
140
+ source: 'blocksync',
141
+ list: true,
142
+ args,
143
+ example: { collectionId: '8250' },
144
+ plan(input, ctx) {
145
+ const member = accountFor(input.address, ctx.identity, 'address');
146
+ if ('invalid' in member) return member;
147
+ const n = ctx.first;
148
+ const filter = literal({
149
+ memberAddress: { equalTo: member.address },
150
+ ...(input.collectionId !== undefined
151
+ ? { collectionId: { equalTo: input.collectionId } }
152
+ : {}),
153
+ });
154
+ return {
155
+ source: 'blocksync',
156
+ field: (alias) =>
157
+ `${alias}: memberBudgets(filter: ${filter}, first: ${n}, orderBy: PRIMARY_KEY_ASC) { ${PAGE} nodes { ${FIELDS} } }`,
158
+ async shape(raw): Promise<KindResult> {
159
+ const parsed = connection(node).safeParse(raw);
160
+ if (!parsed.success) return { ok: false, ...unexpected };
161
+ const now = Date.now();
162
+ return listResult(
163
+ {
164
+ items: await Promise.all(
165
+ parsed.data.nodes.map((row) => budgetOf(ctx, row, now)),
166
+ ),
167
+ more: parsed.data.pageInfo?.hasNextPage ? true : undefined,
168
+ },
169
+ n,
170
+ member.subject,
171
+ );
172
+ },
173
+ };
174
+ },
175
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * What the Blocksync-indexed kinds share: argument schemas and patterns,
3
+ * the clipping every indexed string gets, bank coins and cw20 amounts, and
4
+ * the parsing of the index's timestamps.
5
+ *
6
+ * Every string from the index is chain data written by anyone and is
7
+ * clipped (ids, addresses and dates 120 characters, names and labels 200,
8
+ * long text 240). Amounts stay strings: base units never pass through
9
+ * `Number`. Bank coins are shown through `../coins.ts`.
10
+ */
11
+ import { z } from 'zod';
12
+ import type { UserIdentity } from '../../../../core/user-identity';
13
+ import type { Failure } from '../../http';
14
+ import { accountOf, literal, type Subject } from '../../queries';
15
+ import { EXCERPT_CHARS, ID_CHARS, clip, isRecord, text } from '../../summaries';
16
+ import {
17
+ COIN_CAP,
18
+ displayCoins,
19
+ wholeUnits,
20
+ type ChainCoin,
21
+ type DisplayCoin,
22
+ } from '../coins';
23
+ import type { KindContext, KindInvalid, KindResult } from '../types';
24
+
25
+ const COLLECTION_ID = /^\d{1,12}$/;
26
+ /** A claim id as the core `claim` kind takes it (a CID such as `bafkrei…`). */
27
+ const CLAIM_ID = /^[a-z0-9]{20,100}$/i;
28
+ /** A CosmWasm contract address: `ixo1` and 58 bech32 characters (32 bytes). */
29
+ export const CONTRACT_ADDRESS = /^ixo1[02-9ac-hj-np-z]{58}$/;
30
+
31
+ const LIST_CAP = 10;
32
+
33
+ export const collectionIdArg = (role: string) =>
34
+ z
35
+ .string()
36
+ .regex(COLLECTION_ID, 'a collection number such as "8250"')
37
+ .optional()
38
+ .describe(role);
39
+
40
+ export const claimIdArg = (role: string) =>
41
+ z
42
+ .string()
43
+ .regex(CLAIM_ID, 'a claim id (a CID such as "bafkrei…")')
44
+ .optional()
45
+ .describe(role);
46
+
47
+ /** The account an account argument names (the user's by default), or why it cannot be asked. */
48
+ export function accountFor(
49
+ input: string | undefined,
50
+ identity: UserIdentity,
51
+ role: string,
52
+ ): { address: string; subject: Subject } | KindInvalid {
53
+ const account = accountOf(input, identity, role);
54
+ return account.ok
55
+ ? { address: account.address, subject: account.subject }
56
+ : { invalid: account.detail };
57
+ }
58
+
59
+ /** `{ column: { equalTo: value } }` as a GraphQL filter literal. */
60
+ export const equalTo = (column: string, value: string): string =>
61
+ literal({ [column]: { equalTo: value } });
62
+
63
+ /** An id, address or date, clipped to {@link ID_CHARS}. */
64
+ export const idText = (value: unknown) => text(value, ID_CHARS);
65
+
66
+ /** Long free text (a reason, a description), clipped to {@link EXCERPT_CHARS}. */
67
+ export const longText = (value: unknown) => text(value, EXCERPT_CHARS);
68
+
69
+ /** An amount (string or number) as clipped text; undefined otherwise. */
70
+ export function amountText(value: unknown): string | undefined {
71
+ return typeof value === 'string' || typeof value === 'number'
72
+ ? clip(String(value), 80)
73
+ : undefined;
74
+ }
75
+
76
+ /** Drops undefined entries so absent fields cost nothing in the answer. */
77
+ export function compact<T extends Record<string, unknown>>(value: T): T {
78
+ for (const key of Object.keys(value))
79
+ if (value[key] === undefined) delete value[key];
80
+ return value;
81
+ }
82
+
83
+ export const nonEmpty = <T>(list: T[]): T[] | undefined =>
84
+ list.length ? list : undefined;
85
+
86
+ /**
87
+ * One `{ denom, amount }` bank coin from the index, its amount in whole
88
+ * base units; undefined for anything else (an amount that is not a
89
+ * non-negative decimal included).
90
+ */
91
+ export function chainCoin(value: unknown): ChainCoin | undefined {
92
+ if (!isRecord(value)) return undefined;
93
+ const denom = idText(value.denom);
94
+ const amount = amountText(value.amount);
95
+ const units = amount === undefined ? null : wholeUnits(amount);
96
+ return denom && units !== null ? { denom, amount: units } : undefined;
97
+ }
98
+
99
+ /** The bank coins of a JSON coin array, at most {@link COIN_CAP}; anything else is left out. */
100
+ export function chainCoins(value: unknown): ChainCoin[] {
101
+ if (!Array.isArray(value)) return [];
102
+ const out: ChainCoin[] = [];
103
+ for (const entry of value) {
104
+ if (out.length >= COIN_CAP) break;
105
+ const coin = chainCoin(entry);
106
+ if (coin) out.push(coin);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** The bank coins of a JSON coin array as shown; undefined when there are none. */
112
+ export async function bankCoins(
113
+ ctx: Pick<KindContext, 'display'>,
114
+ value: unknown,
115
+ ): Promise<DisplayCoin[] | undefined> {
116
+ return nonEmpty(await displayCoins(ctx, chainCoins(value)));
117
+ }
118
+
119
+ /** cw20 amounts (`[{ address, amount }]`, base units of each token contract), at most ten. */
120
+ export function cw20Amounts(
121
+ value: unknown,
122
+ ): { address: string; amountBaseUnits: string }[] {
123
+ if (!Array.isArray(value)) return [];
124
+ const out: { address: string; amountBaseUnits: string }[] = [];
125
+ for (const entry of value) {
126
+ if (out.length >= LIST_CAP) break;
127
+ if (!isRecord(entry)) continue;
128
+ const address = idText(entry.address);
129
+ const amountBaseUnits = amountText(entry.amount);
130
+ if (address && amountBaseUnits) out.push({ address, amountBaseUnits });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * cw1155 amounts (x/claims `CW1155Payment`: `[{ address, token_id, amount }]`,
137
+ * whole units of the listed token ids of each contract), at most ten.
138
+ */
139
+ export function cw1155Amounts(
140
+ value: unknown,
141
+ ): { address: string; tokenIds?: string[]; amount: string }[] {
142
+ if (!Array.isArray(value)) return [];
143
+ const out: { address: string; tokenIds?: string[]; amount: string }[] = [];
144
+ for (const entry of value) {
145
+ if (out.length >= LIST_CAP) break;
146
+ if (!isRecord(entry)) continue;
147
+ const address = idText(entry.address);
148
+ const amount = amountText(entry.amount);
149
+ const ids = entry.token_id ?? entry.tokenId;
150
+ const tokenIds = Array.isArray(ids)
151
+ ? ids.slice(0, LIST_CAP).flatMap((id) => {
152
+ const shown = idText(id);
153
+ return shown ? [shown] : [];
154
+ })
155
+ : [];
156
+ if (address && amount)
157
+ out.push(compact({ address, tokenIds: nonEmpty(tokenIds), amount }));
158
+ }
159
+ return out;
160
+ }
161
+
162
+ const OFFSET = /(Z|[+-]\d{2}:?\d{2})$/;
163
+
164
+ /**
165
+ * Epoch ms of an index timestamp. Blocksync's `TIMESTAMP(3)` columns hold
166
+ * UTC without an offset (`2026-10-29T14:55:29.676`), so one without an
167
+ * offset is read as UTC; NaN when it is not a date.
168
+ */
169
+ function timeOf(value: string): number {
170
+ return Date.parse(OFFSET.test(value) ? value : `${value}Z`);
171
+ }
172
+
173
+ /** Whether `value` is an index timestamp at or before `now`; undefined when it is not one. */
174
+ export function reached(value: unknown, now: number): boolean | undefined {
175
+ if (typeof value !== 'string') return undefined;
176
+ const at = timeOf(value);
177
+ return Number.isNaN(at) ? undefined : at <= now;
178
+ }
179
+
180
+ export const unexpected: Failure = {
181
+ reason: 'unavailable',
182
+ detail: 'Blocksync answered this request in an unexpected shape.',
183
+ retryable: false,
184
+ };
185
+
186
+ /** A list answer: the rows, and `more` when the index holds rows past them. */
187
+ export const connection = <T extends z.ZodType>(node: T) =>
188
+ z.looseObject({
189
+ pageInfo: z.object({ hasNextPage: z.boolean() }).optional(),
190
+ nodes: z.array(node),
191
+ });
192
+
193
+ export const PAGE = 'pageInfo { hasNextPage }';
194
+
195
+ /** A list kind's answer by account or filter. */
196
+ export function listResult(
197
+ data: Record<string, unknown>,
198
+ first: number,
199
+ subject?: Subject,
200
+ ): KindResult {
201
+ return {
202
+ ok: true,
203
+ data: compact(data),
204
+ first,
205
+ ...(subject ? { subject } : {}),
206
+ };
207
+ }
208
+
209
+ export const str = z.string();
210
+ export const opt = z.string().nullish();
211
+ export const num = z.union([z.string(), z.number()]).nullish();