@ixo/oracle-runtime-workers 0.22.0 → 0.23.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 (39) hide show
  1. package/README.md +15 -13
  2. package/package.json +10 -4
  3. package/src/action-proposals/policy.ts +54 -0
  4. package/src/action-proposals/reconcile.ts +87 -0
  5. package/src/action-proposals/store.ts +863 -0
  6. package/src/action-proposals/topic-budget.ts +84 -0
  7. package/src/action-proposals/topic-chain.ts +272 -0
  8. package/src/action-proposals/topic-effects.ts +44 -0
  9. package/src/action-proposals/topic-errors.ts +79 -0
  10. package/src/action-proposals/topic-history.ts +267 -0
  11. package/src/action-proposals/topic-matrix.ts +292 -0
  12. package/src/action-proposals/topic-prepare.ts +299 -0
  13. package/src/action-proposals/topic-store.ts +999 -0
  14. package/src/action-proposals/topic-verify.ts +279 -0
  15. package/src/action-proposals/verify.ts +223 -0
  16. package/src/channels/auth.ts +6 -1
  17. package/src/channels/contract.ts +4 -0
  18. package/src/channels/turns.ts +1 -0
  19. package/src/core/env.ts +89 -5
  20. package/src/core/main-agent-types.ts +8 -0
  21. package/src/core/main-agent.ts +2 -0
  22. package/src/core/runtime-context.ts +12 -0
  23. package/src/delivery/plan.ts +43 -2
  24. package/src/delivery/schema.ts +25 -1
  25. package/src/delivery/types.ts +13 -2
  26. package/src/do/ambient.ts +6 -0
  27. package/src/do/contracts.ts +39 -0
  28. package/src/do/user-oracle-do.ts +1253 -7
  29. package/src/hub-actions/client.ts +163 -0
  30. package/src/hub-actions/request.ts +110 -0
  31. package/src/hub-actions/store.ts +485 -0
  32. package/src/matrix/gateway-do.ts +176 -0
  33. package/src/matrix/reply-parts.ts +8 -2
  34. package/src/plugin-api/types.ts +54 -0
  35. package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
  36. package/src/plugins/action-proposals/evidence.ts +67 -0
  37. package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
  38. package/src/plugins/index.ts +6 -0
  39. package/src/shell/app.ts +195 -1
@@ -0,0 +1,84 @@
1
+ import { TopicReadUnavailable, typedTopicError } from './topic-errors';
2
+ import type { TopicMatrixReader } from './topic-matrix';
3
+ export const TOPIC_READ_BUDGET_MS = 10_000;
4
+ export async function withTopicReadBudget<T>(
5
+ reader: TopicMatrixReader,
6
+ fetcher: typeof fetch,
7
+ run: (reader: TopicMatrixReader, fetcher: typeof fetch) => Promise<T>,
8
+ /** An outer budget (the alarm tick's): its abort ends this read too. */
9
+ outer?: AbortSignal,
10
+ ): Promise<T> {
11
+ const controller = new AbortController();
12
+ const deadlineAt = Date.now() + TOPIC_READ_BUDGET_MS;
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ let rejectDeadline: (error: unknown) => void = () => undefined;
15
+ const deadline = new Promise<never>((_resolve, reject) => {
16
+ rejectDeadline = reject;
17
+ timer = setTimeout(() => {
18
+ controller.abort();
19
+ reject(
20
+ new TopicReadUnavailable(
21
+ 'Topic verification exceeded the ten second budget',
22
+ ),
23
+ );
24
+ }, TOPIC_READ_BUDGET_MS);
25
+ });
26
+ const onOuterAbort = () => {
27
+ controller.abort();
28
+ rejectDeadline(
29
+ new TopicReadUnavailable('Topic verification budget expired'),
30
+ );
31
+ };
32
+ if (outer?.aborted) onOuterAbort();
33
+ else outer?.addEventListener('abort', onOuterAbort, { once: true });
34
+ const active = () => {
35
+ if (controller.signal.aborted)
36
+ throw new TopicReadUnavailable('Topic verification budget expired');
37
+ };
38
+ const bounded: TopicMatrixReader = {
39
+ state: async (room) => {
40
+ active();
41
+ const value = await reader.state(room);
42
+ active();
43
+ return value;
44
+ },
45
+ event: async (room, id, opts) => {
46
+ active();
47
+ const value = await reader.event(room, id, {
48
+ ...opts,
49
+ deadlineAt: Math.min(opts?.deadlineAt ?? Infinity, deadlineAt),
50
+ });
51
+ active();
52
+ return value;
53
+ },
54
+ history: async (room, root, opts) => {
55
+ active();
56
+ const value = await reader.history(room, root, {
57
+ ...opts,
58
+ deadlineAt: Math.min(opts?.deadlineAt ?? Infinity, deadlineAt),
59
+ });
60
+ active();
61
+ return value;
62
+ },
63
+ };
64
+ const fetchBounded: typeof fetch = (input, init) => {
65
+ active();
66
+ return fetcher(input, { ...init, signal: controller.signal }).then(
67
+ (response) => {
68
+ active();
69
+ return response;
70
+ },
71
+ );
72
+ };
73
+ try {
74
+ return await Promise.race([run(bounded, fetchBounded), deadline]);
75
+ } catch (error) {
76
+ // The budget is where every authority read ends: whatever leaves it is
77
+ // a conflict, unavailable, or a bug.
78
+ throw typedTopicError(error);
79
+ } finally {
80
+ if (timer) clearTimeout(timer);
81
+ outer?.removeEventListener('abort', onOuterAbort);
82
+ controller.abort();
83
+ }
84
+ }
@@ -0,0 +1,272 @@
1
+ import protobuf from 'protobufjs/minimal.js';
2
+ import { z } from 'zod';
3
+ import {
4
+ QueryEntityRequest,
5
+ QueryEntityResponse,
6
+ QueryParamsRequest,
7
+ QueryParamsResponse,
8
+ } from '@ixo/impactxclient-sdk/module/codegen/ixo/entity/v1beta1/query.js';
9
+ import {
10
+ QueryCollectionRequest,
11
+ QueryCollectionResponse,
12
+ } from '@ixo/impactxclient-sdk/module/codegen/ixo/claims/v1beta1/query.js';
13
+ import {
14
+ QueryGrantsRequest,
15
+ QueryGrantsResponse,
16
+ } from '@ixo/impactxclient-sdk/module/codegen/cosmos/authz/v1beta1/query.js';
17
+ import {
18
+ QuerySmartContractStateRequest,
19
+ QuerySmartContractStateResponse,
20
+ } from '@ixo/impactxclient-sdk/module/codegen/cosmwasm/wasm/v1/query.js';
21
+ import { IxoAddressSchema } from '@ixo/ixo-transaction/action-proposals';
22
+ import { boundedBytes } from '../core/domain-context/transport';
23
+ import { digestTopicProposalEffect } from './topic-store';
24
+ import { ActionProposalConflict } from './store';
25
+ import { TopicReadUnavailable } from './topic-errors';
26
+ import { chainNetwork } from './verify';
27
+ const encode64 = (v: Uint8Array) =>
28
+ btoa(Array.from(v, (c) => String.fromCharCode(c)).join(''));
29
+ const decode64 = (v: string) =>
30
+ Uint8Array.from(atob(v), (c) => c.charCodeAt(0));
31
+ const hex = (v: Uint8Array) =>
32
+ Array.from(v, (c) => c.toString(16).padStart(2, '0')).join('');
33
+ export class TopicChainReader {
34
+ constructor(
35
+ private readonly rpc: string,
36
+ private readonly chainId: string,
37
+ private readonly fetcher: typeof fetch = fetch,
38
+ ) {}
39
+ private async request(path: string) {
40
+ const base = new URL(this.rpc);
41
+ if (
42
+ base.protocol !== 'https:' ||
43
+ base.username ||
44
+ base.password ||
45
+ base.search ||
46
+ base.hash
47
+ )
48
+ throw new TopicReadUnavailable('Invalid trusted RPC');
49
+ const res = await this.fetcher(
50
+ new URL(path, `${base.href.replace(/\/$/, '')}/`),
51
+ { redirect: 'error', signal: AbortSignal.timeout(10000) },
52
+ );
53
+ if (!res.ok) throw new TopicReadUnavailable('Chain authority unavailable');
54
+ return JSON.parse(
55
+ new TextDecoder().decode(await boundedBytes(res, 524288)),
56
+ ) as unknown;
57
+ }
58
+ // The node's envelopes are transport: an answer this reader does not
59
+ // understand is the node failing, not a fact about the chain.
60
+ // The network an origin serves comes from the per-isolate `/status`
61
+ // cache the transfer verifier uses (5 minutes), not a read per call.
62
+ async checkChain() {
63
+ let network: string;
64
+ try {
65
+ network = await chainNetwork(new URL(this.rpc).href, () =>
66
+ this.request('status'),
67
+ );
68
+ } catch (error) {
69
+ if (error instanceof TopicReadUnavailable) throw error;
70
+ throw new TopicReadUnavailable('Chain authority unavailable');
71
+ }
72
+ if (network !== this.chainId)
73
+ throw new TopicReadUnavailable('Chain authority mismatch');
74
+ }
75
+ private async query(path: string, data: Uint8Array) {
76
+ const r = z
77
+ .object({
78
+ result: z.object({
79
+ response: z.object({
80
+ code: z.number().optional(),
81
+ value: z.string(),
82
+ height: z.string(),
83
+ }),
84
+ }),
85
+ })
86
+ .safeParse(
87
+ await this.request(
88
+ `abci_query?path=${encodeURIComponent(JSON.stringify(path))}&data=0x${hex(data)}&prove=false`,
89
+ ),
90
+ );
91
+ if (!r.success)
92
+ throw new TopicReadUnavailable('Chain authority unavailable');
93
+ const response = r.data.result.response;
94
+ if (response.code)
95
+ throw new TopicReadUnavailable('Authoritative chain query failed');
96
+ return decode64(response.value);
97
+ }
98
+ async collection(
99
+ entityDid: string,
100
+ collectionId: string,
101
+ signerAddress: string,
102
+ ) {
103
+ await this.checkChain();
104
+ const entity = QueryEntityResponse.decode(
105
+ await this.query(
106
+ '/ixo.entity.v1beta1.Query/Entity',
107
+ QueryEntityRequest.encode(
108
+ { id: entityDid },
109
+ new protobuf.Writer(),
110
+ ).finish(),
111
+ ),
112
+ ).entity;
113
+ const collection = QueryCollectionResponse.decode(
114
+ await this.query(
115
+ '/ixo.claims.v1beta1.Query/Collection',
116
+ QueryCollectionRequest.encode(
117
+ { id: collectionId },
118
+ new protobuf.Writer(),
119
+ ).finish(),
120
+ ),
121
+ ).collection;
122
+ const params = QueryParamsResponse.decode(
123
+ await this.query(
124
+ '/ixo.entity.v1beta1.Query/Params',
125
+ QueryParamsRequest.encode({}, new protobuf.Writer()).finish(),
126
+ ),
127
+ ).params;
128
+ const admin = entity?.accounts.find((a) => a.name === 'admin');
129
+ if (
130
+ !entity ||
131
+ entity.id !== entityDid ||
132
+ !collection ||
133
+ collection.id !== collectionId ||
134
+ collection.entity !== entityDid ||
135
+ !admin ||
136
+ collection.admin !== admin.address ||
137
+ !params?.nftContractAddress
138
+ )
139
+ throw new ActionProposalConflict(
140
+ 'Entity collection and admin binding mismatch',
141
+ );
142
+ IxoAddressSchema.parse(admin.address);
143
+ const smart = QuerySmartContractStateRequest.fromPartial({
144
+ address: params.nftContractAddress,
145
+ queryData: new TextEncoder().encode(
146
+ JSON.stringify({
147
+ owner_of: { token_id: entityDid, include_expired: false },
148
+ }),
149
+ ),
150
+ });
151
+ const owner = QuerySmartContractStateResponse.decode(
152
+ await this.query(
153
+ '/cosmwasm.wasm.v1.Query/SmartContractState',
154
+ QuerySmartContractStateRequest.encode(
155
+ smart,
156
+ new protobuf.Writer(),
157
+ ).finish(),
158
+ ),
159
+ );
160
+ const nft = z
161
+ .object({ owner: IxoAddressSchema })
162
+ .parse(JSON.parse(new TextDecoder().decode(owner.data)));
163
+ if (nft.owner !== signerAddress)
164
+ throw new ActionProposalConflict(
165
+ 'Signing account is not the entity NFT owner',
166
+ );
167
+ return {
168
+ adminAddress: admin.address,
169
+ dependencies: [
170
+ {
171
+ kind: 'entity' as const,
172
+ key: entityDid,
173
+ digest: await digestTopicProposalEffect({
174
+ id: entity.id,
175
+ accounts: entity.accounts,
176
+ }),
177
+ },
178
+ {
179
+ kind: 'collection' as const,
180
+ key: collectionId,
181
+ digest: await digestTopicProposalEffect({
182
+ id: collection.id,
183
+ entity: collection.entity,
184
+ admin: collection.admin,
185
+ }),
186
+ },
187
+ {
188
+ kind: 'nft-owner' as const,
189
+ key: params.nftContractAddress,
190
+ digest: await digestTopicProposalEffect({
191
+ entityDid,
192
+ owner: nft.owner,
193
+ }),
194
+ },
195
+ ],
196
+ };
197
+ }
198
+ async authorization(
199
+ admin: string,
200
+ grantee: string,
201
+ role: 'submit' | 'evaluate',
202
+ ) {
203
+ const typeUrl =
204
+ role === 'submit'
205
+ ? ('/ixo.claims.v1beta1.SubmitClaimAuthorization' as const)
206
+ : ('/ixo.claims.v1beta1.EvaluateClaimAuthorization' as const);
207
+ const messageType =
208
+ role === 'submit'
209
+ ? '/ixo.claims.v1beta1.MsgSubmitClaim'
210
+ : '/ixo.claims.v1beta1.MsgEvaluateClaim';
211
+ const request = QueryGrantsRequest.fromPartial({
212
+ granter: admin,
213
+ grantee,
214
+ msgTypeUrl: messageType,
215
+ });
216
+ const response = QueryGrantsResponse.decode(
217
+ await this.query(
218
+ '/cosmos.authz.v1beta1.Query/Grants',
219
+ QueryGrantsRequest.encode(request, new protobuf.Writer()).finish(),
220
+ ),
221
+ );
222
+ if (response.pagination?.nextKey.length || response.grants.length > 1)
223
+ throw new ActionProposalConflict(
224
+ 'Authorization query exceeds exact single-grant bound',
225
+ );
226
+ const grant = response.grants[0];
227
+ if (grant?.authorization && grant.authorization.typeUrl !== typeUrl)
228
+ throw new ActionProposalConflict('Authorization role mismatch');
229
+ // The grant's expiry on chain covers every constraint in it; the grant
230
+ // builder refuses to merge into it under another expiry.
231
+ const expiresAt = (() => {
232
+ if (!grant?.expiration) return null;
233
+ const seconds = Number(grant.expiration.seconds.toString());
234
+ if (!Number.isSafeInteger(seconds))
235
+ throw new TopicReadUnavailable('Invalid grant expiration');
236
+ return new Date(
237
+ seconds * 1000 + grant.expiration.nanos / 1e6,
238
+ ).toISOString();
239
+ })();
240
+ return {
241
+ existing: grant?.authorization
242
+ ? {
243
+ typeUrl,
244
+ valueBase64: encode64(grant.authorization.value),
245
+ expiresAt,
246
+ }
247
+ : undefined,
248
+ dependency: {
249
+ kind: 'authorization' as const,
250
+ key: `${admin}\0${grantee}\0${role}`,
251
+ digest: await digestTopicProposalEffect(
252
+ grant
253
+ ? {
254
+ authorization: grant.authorization
255
+ ? {
256
+ typeUrl: grant.authorization.typeUrl,
257
+ valueBase64: encode64(grant.authorization.value),
258
+ }
259
+ : null,
260
+ expiration: grant.expiration
261
+ ? {
262
+ seconds: grant.expiration.seconds.toString(),
263
+ nanos: grant.expiration.nanos,
264
+ }
265
+ : null,
266
+ }
267
+ : null,
268
+ ),
269
+ },
270
+ };
271
+ }
272
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ materializeCreateStep,
3
+ TopicCreatePlanSchema,
4
+ } from '@ixo/ixo-transaction/topic-create-plan';
5
+ import {
6
+ ExpectedTopicEffectSchema,
7
+ type TopicActionProposal,
8
+ type TopicStepId,
9
+ type ExpectedTopicEffect,
10
+ } from '@ixo/ixo-transaction/topic-proposals';
11
+ export const topicIdFromPlan = (plan: unknown): string =>
12
+ TopicCreatePlanSchema.parse(plan).identity.topicId;
13
+ export async function topicExpectedEffect(
14
+ proposal: TopicActionProposal,
15
+ step: TopicStepId,
16
+ ): Promise<ExpectedTopicEffect> {
17
+ const action = proposal.action;
18
+ if (action.name === 'grant_topic_access') {
19
+ if (step !== 'grant') throw new Error('Invalid grant step');
20
+ return {
21
+ kind: 'chain',
22
+ chainId: action.chainId,
23
+ signerAddress: action.signerAddress,
24
+ memo: action.memo,
25
+ messages: action.grants.map((g) => g.message),
26
+ };
27
+ }
28
+ if (step === 'grant') throw new Error('Invalid creation step');
29
+ const verified = (name: TopicStepId) => {
30
+ const s = proposal.lifecycle.steps.find((s) => s.step === name);
31
+ return s?.status === 'verified' && s.receipt.kind === 'matrix'
32
+ ? s.receipt.eventId
33
+ : undefined;
34
+ };
35
+ const effect = await materializeCreateStep(action.plan, step, {
36
+ rootEventId: verified('anchor'),
37
+ bodyEventId: verified('body'),
38
+ });
39
+ const { step: _step, ...wire } = effect;
40
+ return ExpectedTopicEffectSchema.parse({
41
+ ...wire,
42
+ transactionId: wire.transactionId ?? `${proposal.id}:${step}`,
43
+ });
44
+ }
@@ -0,0 +1,79 @@
1
+ import { TopicProtocolError } from '@ixo/topic-protocol';
2
+ import { ZodError } from 'zod';
3
+ import { ActionProposalConflict } from './store';
4
+
5
+ /**
6
+ * A room, DID or chain read that failed, timed out or came back oversized.
7
+ * Nothing about the card or its authority is known to have changed; the
8
+ * caller retries later. The shell answers it with 503 and a generic message,
9
+ * the reason stays in the log. Everything the reads DID establish about a
10
+ * card having changed is an `ActionProposalConflict` (409) instead, and any
11
+ * other error is a bug that surfaces as one.
12
+ */
13
+ export class TopicReadUnavailable extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = 'TopicReadUnavailable';
17
+ }
18
+ }
19
+
20
+ /** The failures `boundedBytes` (`core/domain-context/transport`) reports. */
21
+ const TRANSPORT_FAILURES = new Set([
22
+ 'document-unavailable',
23
+ 'document-too-large',
24
+ ]);
25
+
26
+ /**
27
+ * Every failure leaving an authority read, typed. The readers throw their
28
+ * own conclusions typed already; what is left is the transport (a fetch
29
+ * that failed or timed out, a body too large or not JSON) and content an
30
+ * authority answered with that this contract does not accept (a Zod
31
+ * refusal of room state, a DID document or a chain query). The first is
32
+ * unavailable, the second a conflict reported without the schema's issue
33
+ * dump. Anything else is a bug and is rethrown as it was.
34
+ */
35
+ export function typedTopicError(
36
+ error: unknown,
37
+ ): ActionProposalConflict | TopicReadUnavailable {
38
+ if (
39
+ error instanceof ActionProposalConflict ||
40
+ error instanceof TopicReadUnavailable
41
+ )
42
+ return error;
43
+ if (
44
+ error instanceof TopicProtocolError ||
45
+ (error instanceof Error && error.name === 'TopicProtocolError')
46
+ ) {
47
+ // A contract body or operation in the room that the topic protocol
48
+ // itself rejects: a fact about the authority, reported by its code. The
49
+ // name check covers a second copy of the package in the bundle.
50
+ const code =
51
+ error instanceof TopicProtocolError
52
+ ? error.code
53
+ : 'code' in error
54
+ ? String(error.code)
55
+ : 'unknown';
56
+ console.warn(
57
+ `[topic-actions] authority content rejected by the topic protocol: ${code}`,
58
+ );
59
+ return new ActionProposalConflict('Topic authority content is malformed');
60
+ }
61
+ if (error instanceof ZodError) {
62
+ console.warn(
63
+ `[topic-actions] authority content rejected: ${error.issues
64
+ .slice(0, 3)
65
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
66
+ .join('; ')}`,
67
+ );
68
+ return new ActionProposalConflict('Topic authority content is malformed');
69
+ }
70
+ if (error instanceof SyntaxError)
71
+ return new TopicReadUnavailable('Topic authority answer is not JSON');
72
+ if (
73
+ error instanceof TypeError ||
74
+ error instanceof DOMException ||
75
+ (error instanceof Error && TRANSPORT_FAILURES.has(error.message))
76
+ )
77
+ return new TopicReadUnavailable(error.message);
78
+ throw error;
79
+ }