@ixo/oracle-runtime-workers 0.27.0 → 0.28.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/README.md +1 -0
- package/package.json +3 -3
- package/src/core/domain-context/turn-context.ts +1 -1
- package/src/core/env.ts +5 -4
- package/src/core/main-agent.ts +31 -5
- package/src/core/prompt-composer.ts +14 -0
- package/src/core/sub-agent-fallback.ts +3 -2
- package/src/core/user-identity.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +6 -2
- package/src/plugins/index.ts +6 -2
- package/src/plugins/ixo-chain/blocksync.ts +214 -0
- package/src/plugins/ixo-chain/denoms.ts +189 -0
- package/src/plugins/ixo-chain/http.ts +388 -0
- package/src/plugins/ixo-chain/index.ts +1 -0
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +543 -0
- package/src/plugins/ixo-chain/queries.ts +401 -0
- package/src/plugins/ixo-chain/rest.ts +79 -0
- package/src/plugins/ixo-chain/summaries.ts +799 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `chain_lookup` requests and the one Blocksync document they become.
|
|
3
|
+
*
|
|
4
|
+
* Every field name, argument and enum below is checked against the
|
|
5
|
+
* ixo-blocksync-api schema (`public/graphql/schema.graphql`; see
|
|
6
|
+
* docs/chain-tools.md for the commit). Ids are checked against their kind's
|
|
7
|
+
* pattern and then written into the document as GraphQL literals, never
|
|
8
|
+
* passed as variables. Every filter is on an indexed column; none is a
|
|
9
|
+
* `Message.value` containment: on an account address those run into
|
|
10
|
+
* Blocksync's 8 s statement timeout on mainnet even beside the `typeUrl`
|
|
11
|
+
* clause that makes them an index lookup. Should one ever be added, it must
|
|
12
|
+
* carry `typeUrl: { notEqualTo: "/ibc.core.client.v1.MsgUpdateClient" }`
|
|
13
|
+
* beside it (the GIN index is partial).
|
|
14
|
+
*/
|
|
15
|
+
import { IxoAddressSchema } from '@ixo/ixo-transaction';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { ixoAddressOf, type UserIdentity } from '../../core/user-identity';
|
|
18
|
+
import type { AliasedField } from './blocksync';
|
|
19
|
+
|
|
20
|
+
const MAX_REQUESTS = 8;
|
|
21
|
+
const MAX_FIRST = 20;
|
|
22
|
+
const DEFAULT_FIRST = 10;
|
|
23
|
+
/**
|
|
24
|
+
* Rows a document of several requests reads in all: each list request is
|
|
25
|
+
* capped at `max(5, floor(40 / requests))`.
|
|
26
|
+
*/
|
|
27
|
+
const SHARED_ROWS = 40;
|
|
28
|
+
const MIN_SHARED_FIRST = 5;
|
|
29
|
+
/** Messages read per transaction in a list (the count of all is kept), and for one transaction. */
|
|
30
|
+
const LIST_TX_MESSAGES = 5;
|
|
31
|
+
const ONE_TX_MESSAGES = 50;
|
|
32
|
+
/** Signers read for one transaction. */
|
|
33
|
+
const TX_SIGNERS = 10;
|
|
34
|
+
|
|
35
|
+
const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
|
|
36
|
+
const IXO_DID = /^did:ixo:[A-Za-z0-9:._-]{1,120}$/;
|
|
37
|
+
const CLAIM_ID = /^[a-z0-9]{20,100}$/i;
|
|
38
|
+
const COLLECTION_ID = /^\d{1,12}$/;
|
|
39
|
+
const TX_HASH = /^[A-F0-9]{64}$/i;
|
|
40
|
+
const CURSOR = /^[A-Za-z0-9+/=_-]{1,200}$/;
|
|
41
|
+
const ENTITY_TYPE = /^[A-Za-z0-9/_.:-]{1,64}$/;
|
|
42
|
+
|
|
43
|
+
// The account-field format and `first`'s meaning are stated once, in the
|
|
44
|
+
// tool description; each field here only names its role. Lengths are not
|
|
45
|
+
// capped here: every string is checked against its kind's bounded pattern
|
|
46
|
+
// before it reaches the document.
|
|
47
|
+
const first = z.number().int().min(1).max(MAX_FIRST).optional();
|
|
48
|
+
const account = (role: string) => z.string().optional().describe(role);
|
|
49
|
+
|
|
50
|
+
export const chainRequestSchema = z.discriminatedUnion('kind', [
|
|
51
|
+
z.strictObject({
|
|
52
|
+
kind: z.literal('claims'),
|
|
53
|
+
agent: account('Claim submitter.'),
|
|
54
|
+
collectionId: z.string().optional(),
|
|
55
|
+
first,
|
|
56
|
+
after: z.string().optional().describe('Previous page endCursor.'),
|
|
57
|
+
}),
|
|
58
|
+
z.strictObject({
|
|
59
|
+
kind: z.literal('claim'),
|
|
60
|
+
claimId: z.string().describe('The claim id (a CID, e.g. bafkrei…).'),
|
|
61
|
+
}),
|
|
62
|
+
z.strictObject({
|
|
63
|
+
kind: z.literal('claim_collection'),
|
|
64
|
+
id: z.string().describe('Collection id, e.g. "8250".'),
|
|
65
|
+
}),
|
|
66
|
+
z.strictObject({
|
|
67
|
+
kind: z.literal('claim_collections'),
|
|
68
|
+
entity: z.string().optional().describe('Its did:ixo:entity:… DID.'),
|
|
69
|
+
admin: account('Collection admin.'),
|
|
70
|
+
first,
|
|
71
|
+
}),
|
|
72
|
+
z.strictObject({
|
|
73
|
+
kind: z.literal('entities'),
|
|
74
|
+
owner: account('Entity owner.'),
|
|
75
|
+
type: z.string().optional().describe('e.g. "dao".'),
|
|
76
|
+
first,
|
|
77
|
+
}),
|
|
78
|
+
z.strictObject({
|
|
79
|
+
kind: z.literal('entity'),
|
|
80
|
+
id: z.string().describe('did:ixo:entity:…'),
|
|
81
|
+
}),
|
|
82
|
+
z.strictObject({
|
|
83
|
+
kind: z.literal('did_document'),
|
|
84
|
+
id: z
|
|
85
|
+
.string()
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("Any did:ixo:… DID; default: the user's."),
|
|
88
|
+
}),
|
|
89
|
+
z.strictObject({
|
|
90
|
+
kind: z.literal('token_balances'),
|
|
91
|
+
address: account('Token holder.'),
|
|
92
|
+
first,
|
|
93
|
+
}),
|
|
94
|
+
z.strictObject({
|
|
95
|
+
kind: z.literal('transactions'),
|
|
96
|
+
address: account('Signer or recipient.'),
|
|
97
|
+
direction: z
|
|
98
|
+
.enum(['signed', 'received'])
|
|
99
|
+
.optional()
|
|
100
|
+
.describe('Default: signed.'),
|
|
101
|
+
first,
|
|
102
|
+
}),
|
|
103
|
+
z.strictObject({
|
|
104
|
+
kind: z.literal('transaction'),
|
|
105
|
+
hash: z.string().describe('64-hex transaction hash.'),
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
export type ChainRequest = z.infer<typeof chainRequestSchema>;
|
|
110
|
+
export type ChainRequestKind = ChainRequest['kind'];
|
|
111
|
+
export type Subject = 'user' | 'other';
|
|
112
|
+
|
|
113
|
+
export const chainLookupSchema = z.strictObject({
|
|
114
|
+
requests: z.array(chainRequestSchema).min(1).max(MAX_REQUESTS),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One request as a document field, or why it cannot be asked. `subject` is
|
|
119
|
+
* known before the read for a request by account; `first` is the rows a
|
|
120
|
+
* list request reads.
|
|
121
|
+
*/
|
|
122
|
+
export type FieldPlan =
|
|
123
|
+
| ({
|
|
124
|
+
ok: true;
|
|
125
|
+
request: ChainRequest;
|
|
126
|
+
kind: ChainRequestKind;
|
|
127
|
+
subject?: Subject;
|
|
128
|
+
first?: number;
|
|
129
|
+
} & AliasedField)
|
|
130
|
+
| {
|
|
131
|
+
ok: false;
|
|
132
|
+
request: ChainRequest;
|
|
133
|
+
kind: ChainRequestKind;
|
|
134
|
+
detail: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
138
|
+
|
|
139
|
+
/** A JSON value as a GraphQL input literal: keys bare, strings quoted as JSON quotes them. */
|
|
140
|
+
export function literal(value: Json): string {
|
|
141
|
+
if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
|
|
142
|
+
if (value && typeof value === 'object')
|
|
143
|
+
return `{ ${Object.entries(value)
|
|
144
|
+
.map(([key, inner]) => {
|
|
145
|
+
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(key))
|
|
146
|
+
throw new Error(`not a GraphQL name: ${key}`);
|
|
147
|
+
return `${key}: ${literal(inner)}`;
|
|
148
|
+
})
|
|
149
|
+
.join(', ')} }`;
|
|
150
|
+
return JSON.stringify(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The rows a list request reads: its `first` (1–20 by the schema, default
|
|
155
|
+
* 10), capped at `max(5, floor(40 / count))` when the document holds
|
|
156
|
+
* `count > 1` requests.
|
|
157
|
+
*/
|
|
158
|
+
export function firstOf(requested: number | undefined, count: number): number {
|
|
159
|
+
const cap =
|
|
160
|
+
count > 1
|
|
161
|
+
? Math.max(MIN_SHARED_FIRST, Math.floor(SHARED_ROWS / count))
|
|
162
|
+
: MAX_FIRST;
|
|
163
|
+
return Math.min(requested ?? DEFAULT_FIRST, cap, MAX_FIRST);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The account an account field names: the user's own address when the
|
|
168
|
+
* field is omitted, else an `ixo1…` address or the address a
|
|
169
|
+
* `did:ixo:ixo1…` DID names.
|
|
170
|
+
*/
|
|
171
|
+
export function accountOf(
|
|
172
|
+
input: string | undefined,
|
|
173
|
+
identity: UserIdentity,
|
|
174
|
+
role: string,
|
|
175
|
+
):
|
|
176
|
+
| { ok: true; address: string; subject: Subject }
|
|
177
|
+
| { ok: false; detail: string } {
|
|
178
|
+
if (input === undefined)
|
|
179
|
+
return identity.address
|
|
180
|
+
? { ok: true, address: identity.address, subject: 'user' }
|
|
181
|
+
: { ok: false, detail: 'The user has no IXO account address; give one.' };
|
|
182
|
+
const address = IxoAddressSchema.safeParse(input).success
|
|
183
|
+
? input
|
|
184
|
+
: ixoAddressOf(input);
|
|
185
|
+
if (!address)
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
detail: `${role} must be an ixo1… address or a did:ixo:ixo1… DID.`,
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
address,
|
|
193
|
+
subject: address === identity.address ? 'user' : 'other',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const CLAIM_FIELDS =
|
|
198
|
+
'claimId collectionId agentDid agentAddress submissionDate paymentsStatus schemaType useIntent amount currentEvaluationId';
|
|
199
|
+
const COLLECTION_FIELDS =
|
|
200
|
+
'id entity admin protocol startDate endDate quota count evaluated approved rejected disputed state';
|
|
201
|
+
const MESSAGE_FIELDS = 'typeUrl index value';
|
|
202
|
+
const TRANSACTION_FIELDS = 'hash height time code memo';
|
|
203
|
+
|
|
204
|
+
const messagesOf = (n: number) =>
|
|
205
|
+
`messagesByTransactionHash(first: ${n}, orderBy: INDEX_ASC) { totalCount nodes { ${MESSAGE_FIELDS} } }`;
|
|
206
|
+
|
|
207
|
+
/** `filter: { a: { equalTo: x }, … }` for the given scalar equalities. */
|
|
208
|
+
function equalities(pairs: [string, string | undefined][]): string {
|
|
209
|
+
return literal(
|
|
210
|
+
Object.fromEntries(
|
|
211
|
+
pairs.flatMap(([key, value]) =>
|
|
212
|
+
value === undefined ? [] : [[key, { equalTo: value }]],
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* One request as an aliased field of the lookup document; `count` is the
|
|
220
|
+
* number of requests the document holds.
|
|
221
|
+
*/
|
|
222
|
+
export function requestField(
|
|
223
|
+
alias: string,
|
|
224
|
+
request: ChainRequest,
|
|
225
|
+
identity: UserIdentity,
|
|
226
|
+
count = 1,
|
|
227
|
+
): FieldPlan {
|
|
228
|
+
const plan = fieldOf(alias, request, identity, count);
|
|
229
|
+
return 'detail' in plan
|
|
230
|
+
? { ok: false, request, kind: request.kind, detail: plan.detail }
|
|
231
|
+
: { ok: true, request, kind: request.kind, alias, ...plan };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const invalid = (detail: string) => ({ detail });
|
|
235
|
+
|
|
236
|
+
function fieldOf(
|
|
237
|
+
alias: string,
|
|
238
|
+
request: ChainRequest,
|
|
239
|
+
identity: UserIdentity,
|
|
240
|
+
count: number,
|
|
241
|
+
): { field: string; subject?: Subject; first?: number } | { detail: string } {
|
|
242
|
+
switch (request.kind) {
|
|
243
|
+
case 'claims': {
|
|
244
|
+
const agent = accountOf(request.agent, identity, 'agent');
|
|
245
|
+
if (!agent.ok) return invalid(agent.detail);
|
|
246
|
+
if (
|
|
247
|
+
request.collectionId !== undefined &&
|
|
248
|
+
!COLLECTION_ID.test(request.collectionId)
|
|
249
|
+
)
|
|
250
|
+
return invalid('collectionId must be a number such as "8250".');
|
|
251
|
+
if (request.after !== undefined && !CURSOR.test(request.after))
|
|
252
|
+
return invalid('after must be an endCursor from an earlier page.');
|
|
253
|
+
// Claim."agentAddress" is indexed and "agentDid" is not: a did:ixo
|
|
254
|
+
// account's claims are read by the address its DID names.
|
|
255
|
+
const filter = equalities([
|
|
256
|
+
['agentAddress', agent.address],
|
|
257
|
+
['collectionId', request.collectionId],
|
|
258
|
+
]);
|
|
259
|
+
const after = request.after ? `, after: ${literal(request.after)}` : '';
|
|
260
|
+
const n = firstOf(request.first, count);
|
|
261
|
+
return {
|
|
262
|
+
subject: agent.subject,
|
|
263
|
+
first: n,
|
|
264
|
+
field: `${alias}: claims(filter: ${filter}, first: ${n}${after}, orderBy: SUBMISSION_DATE_DESC) { totalCount pageInfo { hasNextPage endCursor } nodes { ${CLAIM_FIELDS} evaluation { status evaluationDate } } }`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
case 'claim':
|
|
268
|
+
if (!CLAIM_ID.test(request.claimId))
|
|
269
|
+
return invalid(
|
|
270
|
+
'claimId must be a claim id (a CID such as "bafkrei…").',
|
|
271
|
+
);
|
|
272
|
+
return {
|
|
273
|
+
field: `${alias}: claim(claimId: ${literal(request.claimId)}) { ${CLAIM_FIELDS} evaluation { status evaluationDate oracle agentDid agentAddress reason amount } }`,
|
|
274
|
+
};
|
|
275
|
+
case 'claim_collection':
|
|
276
|
+
if (!COLLECTION_ID.test(request.id))
|
|
277
|
+
return invalid('id must be a collection number such as "8250".');
|
|
278
|
+
return {
|
|
279
|
+
field: `${alias}: claimCollection(id: ${literal(request.id)}) { ${COLLECTION_FIELDS} payments }`,
|
|
280
|
+
};
|
|
281
|
+
case 'claim_collections': {
|
|
282
|
+
if (request.entity !== undefined && !ENTITY_DID.test(request.entity))
|
|
283
|
+
return invalid('entity must be a did:ixo:entity:… DID.');
|
|
284
|
+
// With only an entity named, every collection of that entity;
|
|
285
|
+
// otherwise the admin (the user by default).
|
|
286
|
+
const admin =
|
|
287
|
+
request.entity !== undefined && request.admin === undefined
|
|
288
|
+
? undefined
|
|
289
|
+
: accountOf(request.admin, identity, 'admin');
|
|
290
|
+
if (admin && !admin.ok) return invalid(admin.detail);
|
|
291
|
+
const filter = equalities([
|
|
292
|
+
['entity', request.entity],
|
|
293
|
+
['admin', admin?.address],
|
|
294
|
+
]);
|
|
295
|
+
const n = firstOf(request.first, count);
|
|
296
|
+
// Newest start date first (the id is text: "9" sorts above "10"). A
|
|
297
|
+
// collection without a start date sorts first, as PostgreSQL puts
|
|
298
|
+
// NULLs first in a descending order; x/claims reads the start date on
|
|
299
|
+
// every claim submission, so a collection that takes claims has one.
|
|
300
|
+
return {
|
|
301
|
+
...(admin ? { subject: admin.subject } : {}),
|
|
302
|
+
first: n,
|
|
303
|
+
field: `${alias}: claimCollections(filter: ${filter}, first: ${n}, orderBy: START_DATE_DESC) { totalCount nodes { ${COLLECTION_FIELDS} } }`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
case 'entities': {
|
|
307
|
+
const owner = accountOf(request.owner, identity, 'owner');
|
|
308
|
+
if (!owner.ok) return invalid(owner.detail);
|
|
309
|
+
if (request.type !== undefined && !ENTITY_TYPE.test(request.type))
|
|
310
|
+
return invalid('type must be an entity type such as "dao".');
|
|
311
|
+
const filter = equalities([
|
|
312
|
+
['owner', owner.address],
|
|
313
|
+
['type', request.type],
|
|
314
|
+
]);
|
|
315
|
+
const n = firstOf(request.first, count);
|
|
316
|
+
return {
|
|
317
|
+
subject: owner.subject,
|
|
318
|
+
first: n,
|
|
319
|
+
field: `${alias}: entities(filter: ${filter}, first: ${n}, orderBy: ID_DESC) { totalCount nodes { id type status owner metadata } }`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
case 'entity':
|
|
323
|
+
if (!ENTITY_DID.test(request.id))
|
|
324
|
+
return invalid('id must be a did:ixo:entity:… DID.');
|
|
325
|
+
return {
|
|
326
|
+
field: `${alias}: entity(id: ${literal(request.id)}) { id type status owner accounts settings linkedResource linkedEntity controller service }`,
|
|
327
|
+
};
|
|
328
|
+
case 'did_document': {
|
|
329
|
+
const id = request.id ?? identity.did;
|
|
330
|
+
if (!IXO_DID.test(id))
|
|
331
|
+
return invalid(
|
|
332
|
+
request.id === undefined
|
|
333
|
+
? "The user's DID is not a did:ixo DID; give one."
|
|
334
|
+
: 'id must be a did:ixo:… DID.',
|
|
335
|
+
);
|
|
336
|
+
return {
|
|
337
|
+
subject: id === identity.did ? 'user' : 'other',
|
|
338
|
+
field: `${alias}: iid(id: ${literal(id)}) { id controller verificationMethod linkedResource alsoKnownAs }`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
case 'token_balances': {
|
|
342
|
+
const holder = accountOf(request.address, identity, 'address');
|
|
343
|
+
if (!holder.ok) return invalid(holder.detail);
|
|
344
|
+
const n = firstOf(request.first, count);
|
|
345
|
+
return {
|
|
346
|
+
subject: holder.subject,
|
|
347
|
+
first: n,
|
|
348
|
+
field: `${alias}: tokenBalances(filter: ${equalities([['address', holder.address]])}, first: ${n}, orderBy: AMOUNT_DESC) { totalCount nodes { tokenId amount retired token { name collection tokenClassByName { contractAddress class } } } }`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
case 'transactions': {
|
|
352
|
+
const holder = accountOf(request.address, identity, 'address');
|
|
353
|
+
if (!holder.ok) return invalid(holder.detail);
|
|
354
|
+
const n = firstOf(request.first, count);
|
|
355
|
+
if (request.direction === 'received')
|
|
356
|
+
// `Message.to` (indexed) is the recipient field of the message, or
|
|
357
|
+
// of its first inner message for an authz MsgExec: toAddress,
|
|
358
|
+
// receiver, recipient, recipientAddress or recipientDid. The same
|
|
359
|
+
// filter the Portal's wallet history polls with.
|
|
360
|
+
return {
|
|
361
|
+
subject: holder.subject,
|
|
362
|
+
first: n,
|
|
363
|
+
field: `${alias}: messages(filter: ${equalities([['to', holder.address]])}, first: ${n}, orderBy: ID_DESC) { totalCount nodes { ${MESSAGE_FIELDS} transactionHash transactionByTransactionHash { ${TRANSACTION_FIELDS} } } }`,
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
subject: holder.subject,
|
|
367
|
+
first: n,
|
|
368
|
+
field: `${alias}: transactionSigners(filter: ${equalities([['signerAddress', holder.address]])}, first: ${n}, orderBy: TRANSACTION_ID_DESC) { totalCount nodes { transactionHash transaction { ${TRANSACTION_FIELDS} ${messagesOf(LIST_TX_MESSAGES)} } } }`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
case 'transaction':
|
|
372
|
+
if (!TX_HASH.test(request.hash))
|
|
373
|
+
return invalid('hash must be a 64-character hex transaction hash.');
|
|
374
|
+
return {
|
|
375
|
+
field: `${alias}: transactions(filter: ${equalities([['hash', request.hash.toUpperCase()]])}, first: 1) { nodes { ${TRANSACTION_FIELDS} fee transactionSignersByTransactionId(first: ${TX_SIGNERS}) { nodes { signerAddress } } ${messagesOf(ONE_TX_MESSAGES)} } }`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The alias of the request at `index`. */
|
|
381
|
+
const aliasOf = (index: number) => `r${index}`;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Every askable request as an aliased document field (aliases `r0…r7`, by
|
|
385
|
+
* position) and each request's plan. `fields` is empty when no request can
|
|
386
|
+
* be asked.
|
|
387
|
+
*/
|
|
388
|
+
export function buildDocument(
|
|
389
|
+
requests: readonly ChainRequest[],
|
|
390
|
+
identity: UserIdentity,
|
|
391
|
+
): { fields: AliasedField[]; plans: FieldPlan[] } {
|
|
392
|
+
const plans = requests.map((request, index) =>
|
|
393
|
+
requestField(aliasOf(index), request, identity, requests.length),
|
|
394
|
+
);
|
|
395
|
+
return {
|
|
396
|
+
fields: plans.flatMap((plan) =>
|
|
397
|
+
plan.ok ? [{ alias: plan.alias, field: plan.field }] : [],
|
|
398
|
+
),
|
|
399
|
+
plans,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain REST (the node's LCD gateway) reads: one GET, bounded and parsed,
|
|
3
|
+
* under the shared retry policy.
|
|
4
|
+
*/
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
import {
|
|
7
|
+
exchange,
|
|
8
|
+
unavailable,
|
|
9
|
+
withRetry,
|
|
10
|
+
type ChainCall,
|
|
11
|
+
type Outcome,
|
|
12
|
+
} from './http';
|
|
13
|
+
|
|
14
|
+
export const REST_MAX_BYTES = 512 * 1024;
|
|
15
|
+
/** Per-attempt wall bound for one REST read. */
|
|
16
|
+
export const REST_ATTEMPT_TIMEOUT_MS = 8_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `GET ${restUrl}${path}` parsed by `schema`. `label` names the read in the
|
|
20
|
+
* log line (never the path: it carries an address). A body that is not
|
|
21
|
+
* JSON or not the expected shape is `unavailable` and is not retried. A 404
|
|
22
|
+
* is `unavailable` unless `notFoundOn404` (a resource that may be absent).
|
|
23
|
+
*/
|
|
24
|
+
export function restJson<S extends z.ZodType>(
|
|
25
|
+
call: ChainCall,
|
|
26
|
+
path: string,
|
|
27
|
+
schema: S,
|
|
28
|
+
opts: {
|
|
29
|
+
label: string;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
notFoundOn404?: boolean;
|
|
33
|
+
},
|
|
34
|
+
): Promise<Outcome<z.infer<S>>> {
|
|
35
|
+
const base = call.restUrl.replace(/\/+$/, '');
|
|
36
|
+
const url = `${base}${path}`;
|
|
37
|
+
return withRetry(call, { label: opts.label, upstream: base }, async () => {
|
|
38
|
+
const answer = await exchange(
|
|
39
|
+
call,
|
|
40
|
+
'chain',
|
|
41
|
+
url,
|
|
42
|
+
{ method: 'GET', headers: { accept: 'application/json' } },
|
|
43
|
+
{
|
|
44
|
+
timeoutMs: opts.timeoutMs ?? REST_ATTEMPT_TIMEOUT_MS,
|
|
45
|
+
maxBytes: opts.maxBytes ?? REST_MAX_BYTES,
|
|
46
|
+
notFoundOn404: opts.notFoundOn404 ?? false,
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
if (!answer.ok) return answer;
|
|
50
|
+
let body: unknown;
|
|
51
|
+
try {
|
|
52
|
+
body = JSON.parse(answer.value);
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
failure: unavailable(
|
|
57
|
+
'chain',
|
|
58
|
+
'it answered something that is not JSON',
|
|
59
|
+
false,
|
|
60
|
+
),
|
|
61
|
+
retry: false,
|
|
62
|
+
note: 'malformed JSON',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const parsed = schema.safeParse(body);
|
|
66
|
+
if (!parsed.success)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
failure: unavailable(
|
|
70
|
+
'chain',
|
|
71
|
+
'it answered in an unexpected shape',
|
|
72
|
+
false,
|
|
73
|
+
),
|
|
74
|
+
retry: false,
|
|
75
|
+
note: 'unexpected shape',
|
|
76
|
+
};
|
|
77
|
+
return { ok: true, value: parsed.data };
|
|
78
|
+
});
|
|
79
|
+
}
|