@ixo/oracle-runtime-workers 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +73 -24
- package/src/plugins/ixo-chain/balances.ts +141 -0
- package/src/plugins/ixo-chain/blocksync.ts +11 -6
- package/src/plugins/ixo-chain/denoms.ts +84 -36
- package/src/plugins/ixo-chain/http.ts +300 -63
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
- package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
- package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
- package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
- package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
- package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
- package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
- package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
- package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
- package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
- package/src/plugins/ixo-chain/kinds/core.ts +246 -0
- package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
- package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
- package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
- package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
- package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
- package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
- package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
- package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
- package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
- package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
- package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
- package/src/plugins/ixo-chain/kinds/types.ts +156 -0
- package/src/plugins/ixo-chain/lookup.ts +470 -0
- package/src/plugins/ixo-chain/overview.ts +1060 -0
- package/src/plugins/ixo-chain/queries.ts +170 -138
- package/src/plugins/ixo-chain/rest.ts +129 -54
- package/src/plugins/ixo-chain/summaries.ts +3 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { KindDef } from '../types';
|
|
2
|
+
import { accountKind, smartAccountKind, spendableKind } from './account';
|
|
3
|
+
import { claimIntentsKind, collectionMembershipKind } from './claims';
|
|
4
|
+
import { networkEconomicsKind } from './economics';
|
|
5
|
+
import { govProposalsKind } from './governance';
|
|
6
|
+
import { authzGrantsKind, feeGrantsKind } from './grants';
|
|
7
|
+
import { liquidStakingKind } from './liquid-staking';
|
|
8
|
+
import { stakingKind } from './staking';
|
|
9
|
+
|
|
10
|
+
/** Kinds answered by chain REST reads, in catalogue order. */
|
|
11
|
+
export const CHAIN_KINDS: readonly KindDef[] = [
|
|
12
|
+
feeGrantsKind,
|
|
13
|
+
authzGrantsKind,
|
|
14
|
+
smartAccountKind,
|
|
15
|
+
accountKind,
|
|
16
|
+
stakingKind,
|
|
17
|
+
spendableKind,
|
|
18
|
+
liquidStakingKind,
|
|
19
|
+
networkEconomicsKind,
|
|
20
|
+
govProposalsKind,
|
|
21
|
+
claimIntentsKind,
|
|
22
|
+
collectionMembershipKind,
|
|
23
|
+
];
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `liquid_staking`: the x/liquidstake pools (each mints its own liquid
|
|
3
|
+
* staking token, e.g. PAY), what one token is worth in IXO, and what the
|
|
4
|
+
* account holds of each pool's token. Pools, pool states and the module's
|
|
5
|
+
* parameters come from a 60 s per-isolate cache; the account's balance of
|
|
6
|
+
* each token is read every time.
|
|
7
|
+
*
|
|
8
|
+
* The chain's own `unstake_rate` is `TruncateDec`'d to a whole number
|
|
9
|
+
* (x/liquidstake `GetNetAmountStateForPool`, `StkIXOToNativeToken(1, …)`),
|
|
10
|
+
* so it reads 1 while a PAY is worth more. The value here is the keeper's
|
|
11
|
+
* unstake arithmetic on the account's whole holding (`LiquidUnstake`
|
|
12
|
+
* before the fee): `held × net_amount ÷ stkixo_total_supply` in
|
|
13
|
+
* `LegacyDec`, truncated to a whole base unit.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { defineKind, type KindContext, type KindResult } from '../types';
|
|
17
|
+
import { displayCoin, ixo, wholeUnits } from '../coins';
|
|
18
|
+
import { clip } from '../../summaries';
|
|
19
|
+
import {
|
|
20
|
+
DEC_ONE,
|
|
21
|
+
ReadCache,
|
|
22
|
+
accountFor,
|
|
23
|
+
addressArg,
|
|
24
|
+
coinSchema,
|
|
25
|
+
decPercent,
|
|
26
|
+
decQuo,
|
|
27
|
+
failed,
|
|
28
|
+
parseDec,
|
|
29
|
+
} from './shared';
|
|
30
|
+
|
|
31
|
+
/** Pools, their states and the module parameters are reused for 60 s. */
|
|
32
|
+
export const LIQUIDSTAKE_TTL_MS = 60_000;
|
|
33
|
+
/** Pools read (one page); governance creates them, mainnet has two. */
|
|
34
|
+
const POOL_PAGE = 10;
|
|
35
|
+
/** x/liquidstake pool ids: lower-case letters, digits and inner dashes. */
|
|
36
|
+
const POOL_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
37
|
+
/** The Cosmos SDK bank denom pattern. */
|
|
38
|
+
const DENOM = /^[a-zA-Z][a-zA-Z0-9/:._-]{2,127}$/;
|
|
39
|
+
|
|
40
|
+
const poolsResponse = z.object({
|
|
41
|
+
pools: z.array(
|
|
42
|
+
z.object({
|
|
43
|
+
pool_id: z.string(),
|
|
44
|
+
liquid_bond_denom: z.string(),
|
|
45
|
+
paused: z.boolean().default(false),
|
|
46
|
+
unstake_fee_rate: z.string().optional(),
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
pagination: z.object({ next_key: z.string().nullish() }).nullish(),
|
|
50
|
+
});
|
|
51
|
+
const statesResponse = z.object({
|
|
52
|
+
net_amount_state: z.object({
|
|
53
|
+
stake_rate: z.string(),
|
|
54
|
+
unstake_rate: z.string(),
|
|
55
|
+
stkixo_total_supply: z.string(),
|
|
56
|
+
net_amount: z.string(),
|
|
57
|
+
total_liquid_tokens: z.string(),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
const paramsResponse = z.object({
|
|
61
|
+
module_params: z.object({
|
|
62
|
+
min_liquid_stake_amount: z.string(),
|
|
63
|
+
module_paused: z.boolean(),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
const balanceResponse = z.object({ balance: coinSchema.nullish() });
|
|
67
|
+
|
|
68
|
+
const pools = new ReadCache<z.infer<typeof poolsResponse>>(
|
|
69
|
+
LIQUIDSTAKE_TTL_MS,
|
|
70
|
+
8,
|
|
71
|
+
);
|
|
72
|
+
const states = new ReadCache<z.infer<typeof statesResponse>>(
|
|
73
|
+
LIQUIDSTAKE_TTL_MS,
|
|
74
|
+
64,
|
|
75
|
+
);
|
|
76
|
+
const params = new ReadCache<z.infer<typeof paramsResponse>>(
|
|
77
|
+
LIQUIDSTAKE_TTL_MS,
|
|
78
|
+
8,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const readPools = (ctx: KindContext) =>
|
|
82
|
+
pools.get(ctx, 'pools', () =>
|
|
83
|
+
ctx.rest(
|
|
84
|
+
`/ixo/liquidstake/v1beta1/pools?pagination.limit=${POOL_PAGE}`,
|
|
85
|
+
poolsResponse,
|
|
86
|
+
{ label: 'liquidstake pools' },
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
const readParams = (ctx: KindContext) =>
|
|
90
|
+
params.get(ctx, 'module_params', () =>
|
|
91
|
+
ctx.rest('/ixo/liquidstake/v1beta1/module_params', paramsResponse, {
|
|
92
|
+
label: 'liquidstake params',
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const readState = (ctx: KindContext, poolId: string) =>
|
|
96
|
+
states.get(ctx, poolId, () =>
|
|
97
|
+
ctx.rest(
|
|
98
|
+
`/ixo/liquidstake/v1beta1/pools/${poolId}/states`,
|
|
99
|
+
statesResponse,
|
|
100
|
+
{
|
|
101
|
+
label: 'liquidstake pool state',
|
|
102
|
+
},
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/** A LegacyDec without its trailing zeros: `"1.000000000000000000"` → `"1"`. */
|
|
107
|
+
const rateText = (rate: string) =>
|
|
108
|
+
clip(rate.includes('.') ? rate.replace(/\.?0+$/, '') : rate, 30);
|
|
109
|
+
|
|
110
|
+
/** Decimal places of `rate`. */
|
|
111
|
+
const RATE_PLACES = 6;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The IXO behind one token: `net ÷ supply` (net in 10⁻¹⁸ units) rounded
|
|
115
|
+
* down to {@link RATE_PLACES} places, e.g. `"1.031359"`.
|
|
116
|
+
*/
|
|
117
|
+
function rateOf(net: bigint, supply: bigint): string {
|
|
118
|
+
const scale = 10n ** BigInt(RATE_PLACES);
|
|
119
|
+
const scaled = (net * scale) / (supply * DEC_ONE);
|
|
120
|
+
return `${scaled / scale}.${(scaled % scale).toString().padStart(RATE_PLACES, '0')}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `StkIXOToNativeToken(held, supply, net)` as the keeper computes it:
|
|
125
|
+
* `held` as a decimal `MulTruncate` `net`, `Quo` `supply`, `TruncateDec`.
|
|
126
|
+
* Whole base units.
|
|
127
|
+
*/
|
|
128
|
+
function nativeOf(held: bigint, net: bigint, supply: bigint): string {
|
|
129
|
+
return (decQuo(held * net, supply * DEC_ONE) / DEC_ONE).toString();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `DeductFeeRate(native, feeRate)` as the keeper computes it on unstake:
|
|
134
|
+
* `native MulTruncate (1 − feeRate)`, `TruncateDec`, then `TruncateInt`.
|
|
135
|
+
* Whole base units; null when the rate is not a decimal in [0, 1].
|
|
136
|
+
*/
|
|
137
|
+
function afterUnstakeFee(native: string, feeRate: string): string | null {
|
|
138
|
+
const fee = parseDec(feeRate);
|
|
139
|
+
if (fee === null || fee > DEC_ONE) return null;
|
|
140
|
+
return ((BigInt(native) * (DEC_ONE - fee)) / DEC_ONE).toString();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The pool's state as shown: what a token is worth, and the account's
|
|
145
|
+
* holding in IXO before and after the pool's unstake fee.
|
|
146
|
+
*/
|
|
147
|
+
function stateView(
|
|
148
|
+
net: z.infer<typeof statesResponse>['net_amount_state'],
|
|
149
|
+
held: string,
|
|
150
|
+
feeRate: string | undefined,
|
|
151
|
+
): Record<string, unknown> {
|
|
152
|
+
const amount = parseDec(net.net_amount);
|
|
153
|
+
const supply = wholeUnits(net.stkixo_total_supply);
|
|
154
|
+
const totalStaked = wholeUnits(net.total_liquid_tokens);
|
|
155
|
+
const backed = amount !== null && supply !== null && supply !== '0';
|
|
156
|
+
const native = backed ? nativeOf(BigInt(held), amount, BigInt(supply)) : null;
|
|
157
|
+
const received =
|
|
158
|
+
native !== null && feeRate !== undefined
|
|
159
|
+
? afterUnstakeFee(native, feeRate)
|
|
160
|
+
: native;
|
|
161
|
+
return {
|
|
162
|
+
rate: backed ? rateOf(amount, BigInt(supply)) : null,
|
|
163
|
+
chainStakeRate: rateText(net.stake_rate),
|
|
164
|
+
chainUnstakeRate: rateText(net.unstake_rate),
|
|
165
|
+
...(totalStaked ? { totalStaked: ixo(totalStaked) } : {}),
|
|
166
|
+
...(native !== null ? { valueInIxo: ixo(native) } : {}),
|
|
167
|
+
...(received !== null ? { unstakeReceivesIxo: ixo(received) } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One pool as shown, with the account's holding of its token; a pool
|
|
173
|
+
* whose state could not be read is shown with `stateUnavailable` (its
|
|
174
|
+
* rate, totals and value unknown). The account's balance failing fails
|
|
175
|
+
* the pool.
|
|
176
|
+
*/
|
|
177
|
+
async function poolView(
|
|
178
|
+
ctx: KindContext,
|
|
179
|
+
address: string,
|
|
180
|
+
pool: z.infer<typeof poolsResponse>['pools'][number],
|
|
181
|
+
): Promise<KindResult> {
|
|
182
|
+
const denom = pool.liquid_bond_denom;
|
|
183
|
+
const [state, balance] = await Promise.all([
|
|
184
|
+
readState(ctx, pool.pool_id),
|
|
185
|
+
ctx.rest(
|
|
186
|
+
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${encodeURIComponent(denom)}`,
|
|
187
|
+
balanceResponse,
|
|
188
|
+
{ label: 'liquid staking token balance' },
|
|
189
|
+
),
|
|
190
|
+
]);
|
|
191
|
+
if (!balance.ok) return failed(balance.failure);
|
|
192
|
+
const held = wholeUnits(balance.value.balance?.amount ?? '0') ?? '0';
|
|
193
|
+
const [yourBalance, lstSupply] = await Promise.all([
|
|
194
|
+
displayCoin(ctx, { denom, amount: held }),
|
|
195
|
+
state.ok
|
|
196
|
+
? displayCoin(ctx, {
|
|
197
|
+
denom,
|
|
198
|
+
amount: state.value.net_amount_state.stkixo_total_supply,
|
|
199
|
+
})
|
|
200
|
+
: null,
|
|
201
|
+
]);
|
|
202
|
+
const fee = pool.unstake_fee_rate ? decPercent(pool.unstake_fee_rate) : null;
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
data: {
|
|
206
|
+
id: pool.pool_id,
|
|
207
|
+
lst: { symbol: yourBalance?.symbol ?? denom, denom },
|
|
208
|
+
paused: pool.paused,
|
|
209
|
+
...(fee ? { unstakeFee: fee } : {}),
|
|
210
|
+
...(state.ok
|
|
211
|
+
? stateView(state.value.net_amount_state, held, pool.unstake_fee_rate)
|
|
212
|
+
: { stateUnavailable: true }),
|
|
213
|
+
...(lstSupply ? { lstSupply } : {}),
|
|
214
|
+
yourBalance,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const args = z.strictObject({ address: addressArg });
|
|
220
|
+
|
|
221
|
+
export const liquidStakingKind = defineKind({
|
|
222
|
+
name: 'liquid_staking',
|
|
223
|
+
summary:
|
|
224
|
+
'Liquid staking pools (e.g. PAY): rates, totals and how much of each token an account holds.',
|
|
225
|
+
explain: [
|
|
226
|
+
'Liquid staking (x/liquidstake) is staking too: IXO staked into a pool is delegated to its validators and earns rewards, and the pool mints its own liquid staking token (LST, e.g. PAY from the "pay" pool) one for one. The LST can be held or sent while the IXO stays staked; unstaking burns LST and returns its IXO, minus `unstakeFee`, after the unbonding period.',
|
|
227
|
+
"Per pool: `id`, `lst` (symbol and denom), `paused` (no staking or unstaking now), `rate` = IXO behind one LST now (pool IXO ÷ LST supply, 6 decimals; it grows as rewards accrue; null when the pool has no LST), `totalStaked` (IXO delegated by the pool), `lstSupply`, and for the account `yourBalance` (its LST) and `valueInIxo` (the IXO behind that LST now) and `unstakeReceivesIxo` (what unstaking it now would return after `unstakeFee`, as the chain computes it). `chainStakeRate` / `chainUnstakeRate` are the chain's own figures, truncated by the chain to whole numbers: use `rate`, never them, for what a token is worth. `stateUnavailable` = that pool's state could not be read (no rate or value).",
|
|
228
|
+
'`module.minStake` is the smallest stake accepted, `module.paused` pauses every pool. Amounts are display units with `amountBaseUnits`.',
|
|
229
|
+
].join('\n'),
|
|
230
|
+
source: 'chain',
|
|
231
|
+
list: false,
|
|
232
|
+
args,
|
|
233
|
+
example: {},
|
|
234
|
+
plan(input, ctx) {
|
|
235
|
+
const account = accountFor(ctx, input.address);
|
|
236
|
+
if ('invalid' in account) return account;
|
|
237
|
+
return {
|
|
238
|
+
source: 'chain',
|
|
239
|
+
async run(): Promise<KindResult> {
|
|
240
|
+
const [poolList, moduleParams] = await Promise.all([
|
|
241
|
+
readPools(ctx),
|
|
242
|
+
readParams(ctx),
|
|
243
|
+
]);
|
|
244
|
+
if (!poolList.ok) return failed(poolList.failure);
|
|
245
|
+
if (!moduleParams.ok) return failed(moduleParams.failure);
|
|
246
|
+
const readable = poolList.value.pools.filter(
|
|
247
|
+
(pool) =>
|
|
248
|
+
POOL_ID.test(pool.pool_id) && DENOM.test(pool.liquid_bond_denom),
|
|
249
|
+
);
|
|
250
|
+
const answers = await Promise.all(
|
|
251
|
+
readable.map((pool) => poolView(ctx, account.address, pool)),
|
|
252
|
+
);
|
|
253
|
+
const views = [];
|
|
254
|
+
for (const answer of answers) {
|
|
255
|
+
if (!answer.ok) return answer;
|
|
256
|
+
views.push(answer.data);
|
|
257
|
+
}
|
|
258
|
+
const { min_liquid_stake_amount: min, module_paused: paused } =
|
|
259
|
+
moduleParams.value.module_params;
|
|
260
|
+
const minStake = wholeUnits(min);
|
|
261
|
+
const skipped = poolList.value.pools.length - readable.length;
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
subject: account.subject,
|
|
265
|
+
data: {
|
|
266
|
+
address: account.address,
|
|
267
|
+
module: {
|
|
268
|
+
...(minStake ? { minStake: ixo(minStake) } : {}),
|
|
269
|
+
paused,
|
|
270
|
+
},
|
|
271
|
+
pools: views,
|
|
272
|
+
...(skipped ? { unreadablePools: skipped } : {}),
|
|
273
|
+
...(poolList.value.pagination?.next_key ? { morePools: true } : {}),
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the chain REST kinds share: argument schemas, the account a request
|
|
3
|
+
* is about, the chain's coin JSON, exact decimal arithmetic on the chain's
|
|
4
|
+
* number strings, short labels, and a bounded per-isolate cache of
|
|
5
|
+
* slow-changing reads. Coins are shown through `../coins.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import {
|
|
9
|
+
BUDGET_SPENT,
|
|
10
|
+
hasBudget,
|
|
11
|
+
throwIfAborted,
|
|
12
|
+
untilAborted,
|
|
13
|
+
type Failure,
|
|
14
|
+
type Outcome,
|
|
15
|
+
} from '../../http';
|
|
16
|
+
import { accountOf, type Subject } from '../../queries';
|
|
17
|
+
import { clip } from '../../summaries';
|
|
18
|
+
import { displayCoins, type DisplayCoin } from '../coins';
|
|
19
|
+
import type { KindContext, KindInvalid, KindResult } from '../types';
|
|
20
|
+
|
|
21
|
+
/* ── arguments ────────────────────────────────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
// Account arguments are bounded by `accountOf`'s patterns before any read.
|
|
24
|
+
export const addressArg = z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("An ixo1… address or did:ixo:ixo1… DID; default: the user's.");
|
|
28
|
+
|
|
29
|
+
export const firstArg = z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(20)
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Rows (1–20, default 10).');
|
|
36
|
+
|
|
37
|
+
export const directionArg = z
|
|
38
|
+
.enum(['received', 'given'])
|
|
39
|
+
.optional()
|
|
40
|
+
.describe(
|
|
41
|
+
'received (default): granted to this account; given: granted by it.',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** A claim collection id as x/claims numbers them. */
|
|
45
|
+
export const COLLECTION_ID = /^\d{1,12}$/;
|
|
46
|
+
|
|
47
|
+
/** The account a request names (the user's by default), or why it is refused. */
|
|
48
|
+
export function accountFor(
|
|
49
|
+
ctx: KindContext,
|
|
50
|
+
input: string | undefined,
|
|
51
|
+
): { address: string; subject: Subject } | KindInvalid {
|
|
52
|
+
const account = accountOf(input, ctx.identity, 'address');
|
|
53
|
+
return account.ok
|
|
54
|
+
? { address: account.address, subject: account.subject }
|
|
55
|
+
: { invalid: account.detail };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── results ──────────────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
export const failed = (failure: Failure): KindResult => ({
|
|
61
|
+
ok: false,
|
|
62
|
+
...failure,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The last segment of a type URL: `/cosmos.bank.v1beta1.MsgSend` → `MsgSend`. */
|
|
66
|
+
export function shortType(typeUrl: string): string {
|
|
67
|
+
return clip(typeUrl.slice(typeUrl.lastIndexOf('.') + 1) || typeUrl, 80);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether an RFC 3339 time is before now; false when it does not parse. */
|
|
71
|
+
export function isPast(time: string): boolean {
|
|
72
|
+
const ms = Date.parse(time);
|
|
73
|
+
return Number.isFinite(ms) && ms < Date.now();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A grant's expiry: the time (null = never) and whether it has passed. */
|
|
77
|
+
export function expiryOf(time: string | null | undefined): {
|
|
78
|
+
expiration: string | null;
|
|
79
|
+
expired: boolean;
|
|
80
|
+
} {
|
|
81
|
+
return time
|
|
82
|
+
? { expiration: time, expired: isPast(time) }
|
|
83
|
+
: { expiration: null, expired: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const UNITS = [
|
|
87
|
+
[86_400n, 'day'],
|
|
88
|
+
[3_600n, 'hour'],
|
|
89
|
+
[60n, 'minute'],
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A protobuf Duration as JSON gives it (`"2592000s"`, `"1.5s"`) in words:
|
|
94
|
+
* `30 days`, `1 hour`, `90 minutes`; the largest unit that divides it
|
|
95
|
+
* exactly. The input itself when it is not a duration.
|
|
96
|
+
*/
|
|
97
|
+
export function durationText(duration: string): string {
|
|
98
|
+
const match = /^(\d{1,15})(?:\.\d{1,9})?s$/.exec(duration);
|
|
99
|
+
if (!match?.[1]) return clip(duration, 40);
|
|
100
|
+
const seconds = BigInt(match[1]);
|
|
101
|
+
const unit = UNITS.find(([size]) => seconds >= size && seconds % size === 0n);
|
|
102
|
+
const [count, name] = unit
|
|
103
|
+
? [seconds / unit[0], unit[1]]
|
|
104
|
+
: [seconds, 'second'];
|
|
105
|
+
return `${count} ${name}${count === 1n ? '' : 's'}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A duration in words, or null when it is absent or zero (no duration set). */
|
|
109
|
+
export function setDuration(
|
|
110
|
+
duration: string | null | undefined,
|
|
111
|
+
): string | null {
|
|
112
|
+
return !duration || /^0+(?:\.0+)?s$/.test(duration)
|
|
113
|
+
? null
|
|
114
|
+
: durationText(duration);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The text of `items` with at most `max` named and the rest counted. */
|
|
118
|
+
export function listText(items: readonly string[], max = 5): string {
|
|
119
|
+
const named = items.slice(0, max).join(', ');
|
|
120
|
+
return items.length > max ? `${named} and ${items.length - max} more` : named;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ── coins ────────────────────────────────────────────────────────────── */
|
|
124
|
+
|
|
125
|
+
export const coinSchema = z.object({ denom: z.string(), amount: z.string() });
|
|
126
|
+
export const coinsSchema = z.array(coinSchema);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Bank coins as shown, or `empty` when there are none: the word for what
|
|
130
|
+
* that field's empty list means on chain ("none", "unlimited", "any",
|
|
131
|
+
* "none allowed"), never an empty list.
|
|
132
|
+
*/
|
|
133
|
+
export async function coinsOr<Empty extends string>(
|
|
134
|
+
ctx: Pick<KindContext, 'display'>,
|
|
135
|
+
coins: z.infer<typeof coinsSchema>,
|
|
136
|
+
empty: Empty,
|
|
137
|
+
): Promise<DisplayCoin[] | Empty> {
|
|
138
|
+
const shown = await displayCoins(ctx, coins);
|
|
139
|
+
return shown.length ? shown : empty;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── decimals ─────────────────────────────────────────────────────────── */
|
|
143
|
+
|
|
144
|
+
/** The chain's `LegacyDec`: 18 decimal places. */
|
|
145
|
+
const DEC_PLACES = 18;
|
|
146
|
+
export const DEC_ONE = 10n ** BigInt(DEC_PLACES);
|
|
147
|
+
const DECIMAL = /^(0|[1-9][0-9]{0,59})(?:\.([0-9]{1,18}))?$/;
|
|
148
|
+
|
|
149
|
+
/** A non-negative decimal string as an integer of 10⁻¹⁸ units; null when it is not one. */
|
|
150
|
+
export function parseDec(value: string): bigint | null {
|
|
151
|
+
const match = DECIMAL.exec(value);
|
|
152
|
+
if (!match?.[1]) return null;
|
|
153
|
+
return (
|
|
154
|
+
BigInt(match[1]) * DEC_ONE +
|
|
155
|
+
BigInt((match[2] ?? '').padEnd(DEC_PLACES, '0'))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 10⁻³⁶ units as 10⁻¹⁸ units, rounded half-to-even (`LegacyDec`'s `chopPrecisionAndRound`). */
|
|
160
|
+
function chopHalfEven(value: bigint): bigint {
|
|
161
|
+
const quotient = value / DEC_ONE;
|
|
162
|
+
const remainder = value % DEC_ONE;
|
|
163
|
+
const half = DEC_ONE / 2n;
|
|
164
|
+
if (remainder < half) return quotient;
|
|
165
|
+
if (remainder > half) return quotient + 1n;
|
|
166
|
+
return quotient % 2n === 0n ? quotient : quotient + 1n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `LegacyDec.Mul`: the product rounded half-to-even at 18 decimals. */
|
|
170
|
+
export function decMul(a: bigint, b: bigint): bigint {
|
|
171
|
+
return chopHalfEven(a * b);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `LegacyDec.Quo` of two non-negative decimals (10⁻¹⁸ units, `b` > 0): the
|
|
176
|
+
* quotient truncated at 36 places, then rounded half-to-even at 18.
|
|
177
|
+
*/
|
|
178
|
+
export function decQuo(a: bigint, b: bigint): bigint {
|
|
179
|
+
return chopHalfEven((a * DEC_ONE * DEC_ONE) / b);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Hundredths of a percent as text: `3333n` → `33.33%`. */
|
|
183
|
+
function hundredthsText(hundredths: bigint): string {
|
|
184
|
+
const whole = hundredths / 100n;
|
|
185
|
+
const fraction = (hundredths % 100n).toString().padStart(2, '0');
|
|
186
|
+
const trimmed = fraction.replace(/0+$/, '');
|
|
187
|
+
return trimmed ? `${whole}.${trimmed}%` : `${whole}%`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A decimal fraction as a percentage rounded down to hundredths: `"0.05"` → `5%`; null when it does not parse. */
|
|
191
|
+
export function decPercent(value: string): string | null {
|
|
192
|
+
const scaled = parseDec(value);
|
|
193
|
+
return scaled === null ? null : hundredthsText((scaled * 10_000n) / DEC_ONE);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `part / whole` as a percentage rounded down to hundredths; `0%` of nothing. */
|
|
197
|
+
export function sharePercent(part: bigint, whole: bigint): string {
|
|
198
|
+
return hundredthsText(whole > 0n ? (part * 10_000n) / whole : 0n);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ── cache ────────────────────────────────────────────────────────────── */
|
|
202
|
+
|
|
203
|
+
const caches = new Set<{ clear(): void }>();
|
|
204
|
+
|
|
205
|
+
/** Tests only: forget every cached chain-kind read. */
|
|
206
|
+
export function resetChainKindCaches(): void {
|
|
207
|
+
for (const cache of caches) cache.clear();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Successful reads kept per isolate for `ttlMs`, keyed by REST base and
|
|
212
|
+
* `key`, at most `limit` (the oldest written is dropped first). A failure
|
|
213
|
+
* is never kept. A read in flight is shared by every call that needs the
|
|
214
|
+
* same key. A call that joined a read which then timed out (the upstream,
|
|
215
|
+
* or the launching call's budget) or was aborted by its launching turn
|
|
216
|
+
* reads again itself while its own budget allows; it waits on its own
|
|
217
|
+
* turn's abort and throws only that, never another turn's.
|
|
218
|
+
*/
|
|
219
|
+
export class ReadCache<T> {
|
|
220
|
+
private readonly answers = new Map<string, { value: T; expiresAt: number }>();
|
|
221
|
+
private readonly inflight = new Map<string, Promise<Outcome<T>>>();
|
|
222
|
+
|
|
223
|
+
constructor(
|
|
224
|
+
private readonly ttlMs: number,
|
|
225
|
+
private readonly limit: number,
|
|
226
|
+
) {
|
|
227
|
+
caches.add(this);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async get(
|
|
231
|
+
ctx: KindContext,
|
|
232
|
+
key: string,
|
|
233
|
+
read: () => Promise<Outcome<T>>,
|
|
234
|
+
): Promise<Outcome<T>> {
|
|
235
|
+
const id = `${ctx.call.restUrl}|${key}`;
|
|
236
|
+
const hit = this.answers.get(id);
|
|
237
|
+
if (hit && hit.expiresAt > Date.now())
|
|
238
|
+
return { ok: true, value: hit.value };
|
|
239
|
+
if (hit) this.answers.delete(id);
|
|
240
|
+
const running = this.inflight.get(id);
|
|
241
|
+
if (running) {
|
|
242
|
+
const shared = await this.join(running, ctx);
|
|
243
|
+
if (shared && (shared.ok || shared.failure.reason !== 'timeout'))
|
|
244
|
+
return shared;
|
|
245
|
+
// The shared read timed out, or its launching turn was aborted.
|
|
246
|
+
if (hasBudget(ctx.call)) return this.get(ctx, key, read);
|
|
247
|
+
return shared ?? { ok: false, failure: BUDGET_SPENT };
|
|
248
|
+
}
|
|
249
|
+
const pending = read().finally(() => this.inflight.delete(id));
|
|
250
|
+
this.inflight.set(id, pending);
|
|
251
|
+
const outcome = await pending;
|
|
252
|
+
if (outcome.ok) this.remember(id, outcome.value);
|
|
253
|
+
return outcome;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A read another call started, as this call waits for it: its outcome,
|
|
258
|
+
* or null when it threw (its launching turn was aborted). This turn's
|
|
259
|
+
* own abort is thrown as soon as it happens.
|
|
260
|
+
*/
|
|
261
|
+
private async join(
|
|
262
|
+
running: Promise<Outcome<T>>,
|
|
263
|
+
ctx: KindContext,
|
|
264
|
+
): Promise<Outcome<T> | null> {
|
|
265
|
+
try {
|
|
266
|
+
return await untilAborted(running, ctx.call.signal);
|
|
267
|
+
} catch {
|
|
268
|
+
throwIfAborted(ctx.call.signal);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
clear(): void {
|
|
274
|
+
this.answers.clear();
|
|
275
|
+
this.inflight.clear();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private remember(id: string, value: T): void {
|
|
279
|
+
this.answers.delete(id);
|
|
280
|
+
if (this.answers.size >= this.limit) {
|
|
281
|
+
const oldest = this.answers.keys().next();
|
|
282
|
+
if (!oldest.done) this.answers.delete(oldest.value);
|
|
283
|
+
}
|
|
284
|
+
this.answers.set(id, { value, expiresAt: Date.now() + this.ttlMs });
|
|
285
|
+
}
|
|
286
|
+
}
|