@ixo/oracle-runtime-workers 0.41.3 → 0.42.1

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.
@@ -0,0 +1,901 @@
1
+ /**
2
+ * The bundled chain gateway: creates a POD as ONE `MsgCreateEntity` the
3
+ * user's own Portal wallet signs, the same message the Portal builds when a
4
+ * user creates an entity there (ixo-portal `lib/entity/createEntity.ts`),
5
+ * and reads the new POD back from the broadcast transaction.
6
+ *
7
+ * Why one message: the chain derives the entity DID while the transaction
8
+ * runs (`did:ixo:entity:` + MD5 of `<nft contract>/<create sequence>`), so a
9
+ * later message in the same transaction could only name it by predicting
10
+ * that sequence and fails as a whole when another creation lands first. A
11
+ * claim collection, entity accounts and grants are added afterwards, once
12
+ * the DID is known.
13
+ *
14
+ * prepare (one time budget for every step):
15
+ * 1. The owner is the user: `ctx.user.did` must be an IXO account DID
16
+ * (`did:ixo:ixo1…`), whose address is read off the DID.
17
+ * 2. Their IID document must exist and list the account under
18
+ * `authentication` or `assertionMethod`, the check the chain's ante
19
+ * handler makes before it runs a `MsgCreateEntity` (ixo-blockchain
20
+ * `x/iid/ante/iidtx.go`, `IidDocument.HasRelationship`).
21
+ * 3. Their secp256k1 public key, for the entity's verification methods: a
22
+ * secp key in that IID document that belongs to the address, else the
23
+ * account's `pub_key` from chain REST when it belongs to the address.
24
+ * 4. The profile (name, description) as JSON-LD, reserved on the oracle's
25
+ * homeserver (`matrix-media.ts`) and uploaded unencrypted only once the
26
+ * user approved the batch; the linked resource names its public
27
+ * download URL and the SHA-256 of the exact bytes.
28
+ * 5. The message, in proto-JSON as `@ixo/ixo-transaction` validates it.
29
+ *
30
+ * confirm: the Portal wallet answers with the hash, code and height (no
31
+ * events), so the transaction is looked up on chain REST, again while it is
32
+ * not indexed yet or the node is briefly unavailable, within a bounded time.
33
+ * It must carry the `MsgCreateEntity` the conversation sent (the same
34
+ * profile proof, owned by the user) and create exactly one POD: one entity
35
+ * event and one ownership mint to the user's address, agreeing with the
36
+ * message response.
37
+ *
38
+ * Every chain read is bounded (time, size, retries) through the ixo-chain
39
+ * plugin's `restJson`, and shares the user's chain read permits with it.
40
+ * Expected failures are answered as results; only the turn's abort throws.
41
+ */
42
+ import { sha256 } from '@noble/hashes/sha2';
43
+ import { ripemd160 } from '@noble/hashes/legacy';
44
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
45
+ import type { ITrxMsg, Network } from '@ixo/ixo-transaction';
46
+ import { TxMsgData } from '@ixo/impactxclient-sdk/module/codegen/cosmos/base/abci/v1beta1/abci.js';
47
+ import { MsgCreateEntityResponse } from '@ixo/impactxclient-sdk/module/codegen/ixo/entity/v1beta1/tx.js';
48
+ import { base58btc } from 'multiformats/bases/base58';
49
+ import { z } from 'zod';
50
+ import { fetchWithoutRedirects } from '../../core/domain-context/transport';
51
+ import { ixoAddressOf } from '../../core/user-identity';
52
+ import type { RuntimeContext } from '../../plugin-api/types';
53
+ import {
54
+ MIN_ATTEMPT_MS,
55
+ TOOL_BUDGET_MS,
56
+ sleep,
57
+ throwIfAborted,
58
+ type ChainCall,
59
+ type Failure,
60
+ } from '../ixo-chain/http';
61
+ import { chainRestUrlFor } from '../ixo-chain/ixo-chain.plugin';
62
+ import { bech32Bytes } from '../ixo-chain/queries';
63
+ import { restJson } from '../ixo-chain/rest';
64
+ import type {
65
+ ChainGateway,
66
+ GatewayRefusal,
67
+ GatewayResult,
68
+ PodConfirmation,
69
+ PodProfile,
70
+ SentPodBatch,
71
+ } from './chain-gateway';
72
+ import { domainIndexerUrlFor, relayerNodeDidFor } from './config';
73
+ import { podDomainCardDocument } from './domain-card';
74
+ import {
75
+ documentProof,
76
+ publishPendingDocuments,
77
+ reservePublicDocument,
78
+ type MatrixMediaDeps,
79
+ } from './matrix-media';
80
+
81
+ const CREATE_ENTITY = '/ixo.entity.v1beta1.MsgCreateEntity';
82
+ const CREATE_ENTITY_RESPONSE = '/ixo.entity.v1beta1.MsgCreateEntityResponse';
83
+ const ADD_LINKED_RESOURCE = '/ixo.iid.v1beta1.MsgAddLinkedResource';
84
+ const ENTITY_CREATED_EVENT = 'ixo.entity.v1beta1.EntityCreatedEvent';
85
+ const POD_ENTITY_TYPE = 'pod';
86
+ const SECP_KEY_TYPE = 'EcdsaSecp256k1VerificationKey2019';
87
+ const SECP_PUBKEY_TYPE = '/cosmos.crypto.secp256k1.PubKey';
88
+ const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
89
+ /** The relationships the chain accepts a signer under (`iidtx.go` constraints). */
90
+ const SIGNER_RELATIONSHIPS = ['authentication', 'assertionMethod'] as const;
91
+ /** The linked resource id of an entity's profile, which the chain fills in. */
92
+ const PROFILE_RESOURCE_ID = '{id}#pro';
93
+
94
+ /** How long the entity is valid: the Portal's "forever" (100 years of 365 days). */
95
+ const ENTITY_LIFETIME_MS = 100 * 365 * 24 * 60 * 60 * 1000;
96
+
97
+ /** Total time of one prepare: the chain reads and the homeserver reservation. */
98
+ export const PREPARE_BUDGET_MS = TOOL_BUDGET_MS;
99
+ /** Total time of publishing a batch's documents. */
100
+ export const PUBLISH_BUDGET_MS = TOOL_BUDGET_MS;
101
+ /** Upper bound on one homeserver request. */
102
+ export const HOMESERVER_TIMEOUT_MS = 10_000;
103
+ /** Upper bound on getting the oracle's Matrix device credentials. */
104
+ export const CREDENTIALS_TIMEOUT_MS = 10_000;
105
+ /** How long `confirmPodCreation` looks for the transaction before giving up. */
106
+ export const CONFIRM_BUDGET_MS = TOOL_BUDGET_MS;
107
+ /** The wait between two lookups of a transaction not on chain (or not readable) yet. */
108
+ export const CONFIRM_POLL_MS = 2_000;
109
+ /** Upper bound on the domain indexer's answer. */
110
+ export const INDEXER_TIMEOUT_MS = 10_000;
111
+
112
+ /** What the gateway is built with; every member has a production default. */
113
+ export interface IxoChainGatewayDeps {
114
+ /** Every request (chain REST, the homeserver, the indexer) goes through it. */
115
+ fetch?: typeof fetch;
116
+ /** The clock the entity's start and end dates (and a card's `validFrom`) are read from. */
117
+ now?: () => Date;
118
+ /** Total time `confirmPodCreation` looks for the transaction. */
119
+ confirmBudgetMs?: number;
120
+ /** The wait between two lookups of the transaction. */
121
+ pollIntervalMs?: number;
122
+ /** Total time of one prepare. */
123
+ prepareBudgetMs?: number;
124
+ /** Upper bound on one homeserver request. */
125
+ homeserverTimeoutMs?: number;
126
+ /** Upper bound on getting the oracle's Matrix device credentials. */
127
+ credentialsTimeoutMs?: number;
128
+ /** Every wait (the poll's and a read's retry); tests inject a clock. */
129
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
130
+ }
131
+
132
+ /** The profile document, in the shape the Portal uploads for an entity it creates. */
133
+ export function podProfileDocument(profile: {
134
+ name: string;
135
+ description?: string;
136
+ }): Record<string, unknown> {
137
+ return {
138
+ '@context': {
139
+ ixo: 'https://w3id.org/ixo/ns/protocol/',
140
+ '@id': '@type',
141
+ type: '@type',
142
+ '@protected': false,
143
+ },
144
+ id: 'ixo:entity#profile',
145
+ type: 'profile',
146
+ orgName: profile.name,
147
+ name: profile.name,
148
+ image: '',
149
+ logo: '',
150
+ brand: profile.name,
151
+ location: '',
152
+ description: profile.description ?? '',
153
+ };
154
+ }
155
+
156
+ /**
157
+ * The entity's verification methods for an owner's secp256k1 key, as the
158
+ * Portal's `customMessages.iid.createIidVerificationMethods({ did, pubkey,
159
+ * address, controller: did, type: 'secp' })` makes them, in proto-JSON.
160
+ */
161
+ export function ownerVerifications(owner: {
162
+ did: string;
163
+ address: string;
164
+ publicKey: Uint8Array;
165
+ }): Record<string, unknown>[] {
166
+ const { did, address } = owner;
167
+ const hex = bytesToHex(owner.publicKey);
168
+ const base58 = base58btc.baseEncode(owner.publicKey);
169
+ const secp = (id: string, material: Record<string, string>) => ({
170
+ relationships: ['authentication'],
171
+ method: { id, type: SECP_KEY_TYPE, controller: did, ...material },
172
+ });
173
+ return [
174
+ secp(did, { publicKeyBase58: base58 }),
175
+ secp(`${did}#${hex}`, { publicKeyMultibase: `F${hex}` }),
176
+ secp(`${did}#${base58}`, { publicKeyBase58: base58 }),
177
+ {
178
+ relationships: ['authentication'],
179
+ method: {
180
+ id: `${did}#${address}`,
181
+ type: 'CosmosAccountAddress',
182
+ controller: did,
183
+ blockchainAccountID: address,
184
+ },
185
+ },
186
+ ];
187
+ }
188
+
189
+ /* ── chain reads ──────────────────────────────────────────────────────── */
190
+
191
+ function chainCall(
192
+ ctx: RuntimeContext,
193
+ network: Network,
194
+ io: { fetch: typeof fetch; sleep: ChainCall['sleep'] },
195
+ deadline: number,
196
+ ): ChainCall {
197
+ return {
198
+ network,
199
+ restUrl: chainRestUrlFor(network, ctx.config),
200
+ blocksyncUrl: '',
201
+ user: ctx.user.did,
202
+ deadline,
203
+ signal: ctx.abortSignal,
204
+ fetch: io.fetch,
205
+ ...(io.sleep ? { sleep: io.sleep } : {}),
206
+ };
207
+ }
208
+
209
+ const refuse = (message: string, retryable: boolean): GatewayRefusal => ({
210
+ ok: false,
211
+ message,
212
+ retryable,
213
+ });
214
+
215
+ /** A failed chain read as a refusal the model can act on. */
216
+ const readRefusal = (what: string, failure: Failure): GatewayRefusal =>
217
+ refuse(
218
+ `Could not read ${what} from the chain: ${failure.detail}`,
219
+ failure.retryable,
220
+ );
221
+
222
+ const pubKeySchema = z.object({
223
+ '@type': z.string(),
224
+ key: z.string().optional(),
225
+ });
226
+ const baseAccountSchema = z.object({ pub_key: pubKeySchema.nullish() });
227
+ const accountSchema = z.object({
228
+ account: z.looseObject({ '@type': z.string() }),
229
+ });
230
+
231
+ /** Where each account type keeps its public key; other types never sign. */
232
+ const ACCOUNT_KEY_HOLDER: Record<
233
+ string,
234
+ z.ZodType<z.infer<typeof baseAccountSchema>>
235
+ > = {
236
+ '/cosmos.auth.v1beta1.BaseAccount': baseAccountSchema,
237
+ ...Object.fromEntries(
238
+ [
239
+ '/cosmos.vesting.v1beta1.ContinuousVestingAccount',
240
+ '/cosmos.vesting.v1beta1.DelayedVestingAccount',
241
+ '/cosmos.vesting.v1beta1.PeriodicVestingAccount',
242
+ '/cosmos.vesting.v1beta1.PermanentLockedAccount',
243
+ ].map((type) => [
244
+ type,
245
+ z
246
+ .object({
247
+ base_vesting_account: z.object({ base_account: baseAccountSchema }),
248
+ })
249
+ .transform((account) => account.base_vesting_account.base_account),
250
+ ]),
251
+ ),
252
+ };
253
+
254
+ const verificationMethodSchema = z.object({
255
+ id: z.string(),
256
+ type: z.string(),
257
+ blockchainAccountID: z.string().optional(),
258
+ publicKeyHex: z.string().optional(),
259
+ publicKeyMultibase: z.string().optional(),
260
+ publicKeyBase58: z.string().optional(),
261
+ });
262
+
263
+ type VerificationMethod = z.infer<typeof verificationMethodSchema>;
264
+
265
+ const iidSchema = z.object({
266
+ iidDocument: z.object({
267
+ verificationMethod: z.array(verificationMethodSchema).default([]),
268
+ authentication: z.array(z.string()).default([]),
269
+ assertionMethod: z.array(z.string()).default([]),
270
+ }),
271
+ });
272
+
273
+ type IidDocument = z.infer<typeof iidSchema>['iidDocument'];
274
+
275
+ function base64Bytes(text: string): Uint8Array | null {
276
+ try {
277
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ function hexBytes(text: string): Uint8Array | null {
284
+ if (!/^(?:[0-9a-fA-F]{2})+$/.test(text)) return null;
285
+ return hexToBytes(text.toLowerCase());
286
+ }
287
+
288
+ function base58Bytes(text: string): Uint8Array | null {
289
+ try {
290
+ return base58btc.baseDecode(text);
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * A verification method's key bytes, read the way the chain reads them when
298
+ * it checks a signer (`IidDocument.HasRelationship`): `publicKeyMultibase`
299
+ * as hex after its one-character prefix (the Portal writes `F` + hex),
300
+ * `publicKeyHex` as hex, `publicKeyBase58` as base58.
301
+ */
302
+ function methodKey(method: VerificationMethod): Uint8Array | null {
303
+ if (method.publicKeyMultibase !== undefined)
304
+ return hexBytes(method.publicKeyMultibase.slice(1));
305
+ if (method.publicKeyHex !== undefined) return hexBytes(method.publicKeyHex);
306
+ if (method.publicKeyBase58 !== undefined)
307
+ return base58Bytes(method.publicKeyBase58);
308
+ return null;
309
+ }
310
+
311
+ const sameBytes = (a: Uint8Array, b: Uint8Array): boolean =>
312
+ a.length === b.length && a.every((byte, index) => byte === b[index]);
313
+
314
+ /**
315
+ * The account a 33-byte key stands for: RIPEMD-160 of its SHA-256, as the
316
+ * chain derives a secp256k1 key's address. Null for any other length.
317
+ */
318
+ function keyAccount(key: Uint8Array): Uint8Array | null {
319
+ return key.length === 33 ? ripemd160(sha256(key)) : null;
320
+ }
321
+
322
+ /** Whether the chain counts `method` as the account `address` (`HasRelationship`'s material match). */
323
+ function methodIsAccount(
324
+ method: VerificationMethod,
325
+ address: string,
326
+ accountBytes: Uint8Array,
327
+ ): boolean {
328
+ if (method.blockchainAccountID !== undefined)
329
+ return method.blockchainAccountID === address;
330
+ const key = methodKey(method);
331
+ const account = key ? keyAccount(key) : null;
332
+ return account !== null && sameBytes(account, accountBytes);
333
+ }
334
+
335
+ /**
336
+ * Whether the IID document lets `address` act for its DID the way the
337
+ * chain's ante handler checks a signer: a verification method of that
338
+ * account named under `authentication` or `assertionMethod`.
339
+ */
340
+ function listsSigner(
341
+ doc: IidDocument,
342
+ address: string,
343
+ accountBytes: Uint8Array,
344
+ ): boolean {
345
+ const named = new Set(
346
+ SIGNER_RELATIONSHIPS.flatMap((relationship) => doc[relationship]),
347
+ );
348
+ return doc.verificationMethod.some(
349
+ (method) =>
350
+ named.has(method.id) && methodIsAccount(method, address, accountBytes),
351
+ );
352
+ }
353
+
354
+ /** Whether `key` is a compressed secp256k1 public key of the account `accountBytes`. */
355
+ function isAccountSecpKey(key: Uint8Array, accountBytes: Uint8Array): boolean {
356
+ if (key[0] !== 2 && key[0] !== 3) return false;
357
+ const account = keyAccount(key);
358
+ return account !== null && sameBytes(account, accountBytes);
359
+ }
360
+
361
+ const POD_TAG = '[pod-creator]';
362
+
363
+ /**
364
+ * The owner's secp256k1 public key for the entity's verification methods,
365
+ * after checking that the chain will accept the owner at all: their IID
366
+ * document must exist and list the account as a signer. The key is the IID
367
+ * document's secp key of this account, else the account's own `pub_key`
368
+ * (the chain learns it from the account's first signed transaction).
369
+ */
370
+ async function ownerPublicKey(
371
+ call: ChainCall,
372
+ owner: { did: string; address: string },
373
+ ): Promise<GatewayResult<{ publicKey: Uint8Array }>> {
374
+ const accountBytes = bech32Bytes(owner.address, 'ixo');
375
+ if (!accountBytes)
376
+ return refuse(
377
+ `Your account address ${owner.address} is not a valid ixo address.`,
378
+ false,
379
+ );
380
+ const iid = await restJson(call, `/ixo/did/dids/${owner.did}`, iidSchema, {
381
+ label: 'owner identity',
382
+ tag: POD_TAG,
383
+ noneOn: [404],
384
+ });
385
+ if (!iid.ok) return readRefusal('your identity', iid.failure);
386
+ if ('absent' in iid)
387
+ return refuse(
388
+ `Your identity ${owner.did} is not on chain yet, and the chain refuses to create an entity whose owner has no identity document. ` +
389
+ 'Sign in to the Portal with this wallet once (it creates your identity), then prepare the POD again.',
390
+ false,
391
+ );
392
+ const doc = iid.value.iidDocument;
393
+ if (!listsSigner(doc, owner.address, accountBytes))
394
+ return refuse(
395
+ `Your identity document does not list your account ${owner.address} under authentication or assertionMethod, so the chain would refuse the POD: ` +
396
+ "it accepts a message for an owner only from an account the owner's identity lists. Add the account to your identity, then prepare the POD again.",
397
+ false,
398
+ );
399
+ for (const method of doc.verificationMethod) {
400
+ if (method.type !== SECP_KEY_TYPE) continue;
401
+ const key = methodKey(method);
402
+ if (key && isAccountSecpKey(key, accountBytes))
403
+ return { ok: true, publicKey: key };
404
+ }
405
+ const account = await restJson(
406
+ call,
407
+ `/cosmos/auth/v1beta1/accounts/${owner.address}`,
408
+ accountSchema,
409
+ { label: 'owner account', tag: POD_TAG, noneOn: [404] },
410
+ );
411
+ if (!account.ok) return readRefusal('your account', account.failure);
412
+ if (!('absent' in account)) {
413
+ const holder = ACCOUNT_KEY_HOLDER[account.value.account['@type']];
414
+ const parsed = holder?.safeParse(account.value.account);
415
+ const pubKey = parsed?.success ? parsed.data.pub_key : undefined;
416
+ if (pubKey?.['@type'] === SECP_PUBKEY_TYPE && pubKey.key !== undefined) {
417
+ const key = base64Bytes(pubKey.key);
418
+ if (key && isAccountSecpKey(key, accountBytes))
419
+ return { ok: true, publicKey: key };
420
+ }
421
+ }
422
+ return refuse(
423
+ `There is no public key on chain yet for your account ${owner.address}, so the POD cannot name your key as its owner. ` +
424
+ 'The chain learns it from your first signed transaction: make any transaction from this wallet (or set up your identity in the Portal), then prepare the POD again.',
425
+ false,
426
+ );
427
+ }
428
+
429
+ /* ── the transaction lookup ───────────────────────────────────────────── */
430
+
431
+ const txSchema = z.object({
432
+ tx: z.object({
433
+ body: z.object({
434
+ messages: z.array(z.looseObject({ '@type': z.string() })).default([]),
435
+ }),
436
+ }),
437
+ tx_response: z.object({
438
+ txhash: z.string(),
439
+ height: z.string(),
440
+ code: z.number().int(),
441
+ codespace: z.string().default(''),
442
+ raw_log: z.string().default(''),
443
+ data: z.string().default(''),
444
+ events: z
445
+ .array(
446
+ z.object({
447
+ type: z.string(),
448
+ attributes: z
449
+ .array(z.object({ key: z.string(), value: z.string().default('') }))
450
+ .default([]),
451
+ }),
452
+ )
453
+ .default([]),
454
+ }),
455
+ });
456
+
457
+ type Tx = z.infer<typeof txSchema>;
458
+ type TxEvent = Tx['tx_response']['events'][number];
459
+
460
+ function attribute(event: TxEvent, key: string): string | undefined {
461
+ return event.attributes.find((attr) => attr.key === key)?.value;
462
+ }
463
+
464
+ function parseJson(text: string | undefined): unknown {
465
+ if (text === undefined) return undefined;
466
+ try {
467
+ return JSON.parse(text);
468
+ } catch {
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ /** A `MsgCreateEntity` as chain REST shows it in a transaction body. */
474
+ const createEntityBodySchema = z.object({
475
+ owner_did: z.string(),
476
+ linked_resource: z
477
+ .array(z.object({ id: z.string(), proof: z.string().default('') }))
478
+ .default([]),
479
+ });
480
+
481
+ /** The same message as the conversation sent it (proto-JSON). */
482
+ const createEntitySentSchema = z.object({
483
+ linkedResource: z.array(
484
+ z.object({ id: z.string(), proof: z.string().optional() }),
485
+ ),
486
+ });
487
+
488
+ /** The profile proof of the `MsgCreateEntity` sent for signing. */
489
+ function sentProfileProof(sent: SentPodBatch): string | undefined {
490
+ const parsed = createEntitySentSchema.safeParse(
491
+ sent.messages.find((message) => message.typeUrl === CREATE_ENTITY)?.value,
492
+ );
493
+ return parsed.success
494
+ ? parsed.data.linkedResource.find(
495
+ (resource) => resource.id === PROFILE_RESOURCE_ID,
496
+ )?.proof
497
+ : undefined;
498
+ }
499
+
500
+ const entityEventSchema = z.object({ id: z.string(), type: z.string() });
501
+
502
+ const failed = (message: string): PodConfirmation => ({
503
+ status: 'failed',
504
+ message,
505
+ retryable: false,
506
+ });
507
+
508
+ /** The DIDs and types the message responses in the result data name. */
509
+ function createdResponses(
510
+ data: string,
511
+ ): { entityId: string; entityType: string }[] | null {
512
+ if (data === '') return [];
513
+ const bytes = hexBytes(data);
514
+ if (!bytes) return null;
515
+ try {
516
+ return TxMsgData.decode(bytes)
517
+ .msgResponses.filter(
518
+ (response) => response.typeUrl === CREATE_ENTITY_RESPONSE,
519
+ )
520
+ .map((response) => MsgCreateEntityResponse.decode(response.value));
521
+ } catch {
522
+ return null;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * What `tx` says about the POD the conversation sent: created when it
528
+ * carries that `MsgCreateEntity` (the sent profile proof, owned by the user)
529
+ * and created exactly one POD (one entity event, one ownership mint to the
530
+ * user's address, the message response agreeing); else why it is not.
531
+ */
532
+ function podOfTransaction(
533
+ tx: Tx,
534
+ owner: { did: string; address: string | null },
535
+ sent: SentPodBatch,
536
+ network: Network,
537
+ ): PodConfirmation {
538
+ const result = tx.tx_response;
539
+ if (result.code !== 0)
540
+ return failed(
541
+ `That transaction failed on chain (code ${result.code}${result.codespace ? ` in ${result.codespace}` : ''}), so no POD was created: ${result.raw_log.slice(0, 300)}`,
542
+ );
543
+ const creates = tx.tx.body.messages.filter(
544
+ (message) => message['@type'] === CREATE_ENTITY,
545
+ );
546
+ const body =
547
+ creates.length === 1 ? createEntityBodySchema.safeParse(creates[0]) : null;
548
+ const proof = sentProfileProof(sent);
549
+ if (
550
+ !body?.success ||
551
+ proof === undefined ||
552
+ body.data.linked_resource.find(
553
+ (resource) => resource.id === PROFILE_RESOURCE_ID,
554
+ )?.proof !== proof
555
+ )
556
+ return failed(
557
+ 'That transaction is not the POD batch this conversation sent for signing (it carries another entity creation, or none). Check the transaction hash.',
558
+ );
559
+ if (body.data.owner_did !== owner.did)
560
+ return failed(
561
+ `The entity that transaction created is owned by ${body.data.owner_did.slice(0, 120)}, not by you.`,
562
+ );
563
+ const entityEvents = result.events.filter(
564
+ (event) => event.type === ENTITY_CREATED_EVENT,
565
+ );
566
+ const mints = result.events.filter(
567
+ (event) =>
568
+ event.type === 'wasm' &&
569
+ attribute(event, 'action') === 'mint' &&
570
+ ENTITY_DID.test(attribute(event, 'token_id') ?? ''),
571
+ );
572
+ const [entityEvent] = entityEvents;
573
+ const [mint] = mints;
574
+ if (entityEvents.length !== 1 || mints.length !== 1 || !entityEvent || !mint)
575
+ return failed(
576
+ 'That transaction did not create exactly one entity (one entity event and one ownership token), so it is not a POD creation.',
577
+ );
578
+ const entity = entityEventSchema.safeParse(
579
+ parseJson(attribute(entityEvent, 'entity')),
580
+ );
581
+ if (!entity.success)
582
+ return failed(
583
+ 'The transaction has an entity event the gateway cannot read.',
584
+ );
585
+ const mintOwner = attribute(mint, 'owner') ?? '';
586
+ // The mint's owner is the account that holds the entity: the binding owner.
587
+ if (mintOwner !== owner.address)
588
+ return failed(
589
+ `The entity that transaction created is owned by ${mintOwner.slice(0, 80) || 'no account'} (the ownership token), not by you.`,
590
+ );
591
+ const signer = z
592
+ .string()
593
+ .safeParse(parseJson(attribute(entityEvent, 'signer')));
594
+ if (signer.success && signer.data !== owner.did)
595
+ return failed(
596
+ `The entity that transaction created is owned by ${signer.data.slice(0, 120)} (the entity event), not by you.`,
597
+ );
598
+ const responses = createdResponses(result.data);
599
+ if (responses === null)
600
+ return failed(
601
+ 'The transaction result data is not what the chain writes; the gateway cannot read it.',
602
+ );
603
+ const did = entity.data.id;
604
+ const dids = new Set([
605
+ did,
606
+ attribute(mint, 'token_id'),
607
+ ...responses.map((response) => response.entityId),
608
+ ]);
609
+ if (dids.size !== 1 || !ENTITY_DID.test(did))
610
+ return failed(
611
+ 'That transaction names more than one entity, so it is not a POD creation (which creates exactly one).',
612
+ );
613
+ const type = [entity.data.type, ...responses.map((r) => r.entityType)].find(
614
+ (entityType) => entityType !== POD_ENTITY_TYPE,
615
+ );
616
+ if (type !== undefined)
617
+ return failed(
618
+ `That transaction created a "${type.slice(0, 40)}" entity, not a POD.`,
619
+ );
620
+ return {
621
+ status: 'created',
622
+ podDid: did,
623
+ summary: `Your POD ${did} was created on ${network} in block ${result.height}.`,
624
+ };
625
+ }
626
+
627
+ /* ── the domain indexer ───────────────────────────────────────────────── */
628
+
629
+ /**
630
+ * Ask the domain indexer to index `podDid` now (`POST {indexer}/index`, no
631
+ * credentials: only a forced re-index needs the indexer's admin key). Its
632
+ * own chain poller indexes the card later anyway, so a failure is logged.
633
+ */
634
+ async function requestIndexing(
635
+ ctx: RuntimeContext,
636
+ url: string,
637
+ podDid: string,
638
+ fetchImpl: typeof fetch,
639
+ ): Promise<void> {
640
+ const controller = new AbortController();
641
+ const timer = setTimeout(() => controller.abort(), INDEXER_TIMEOUT_MS);
642
+ const onAbort = () => controller.abort();
643
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
644
+ try {
645
+ const answer = await fetchWithoutRedirects(
646
+ fetchImpl,
647
+ `${url}/index`,
648
+ {
649
+ method: 'POST',
650
+ headers: {
651
+ accept: 'application/json',
652
+ 'content-type': 'application/json',
653
+ },
654
+ body: JSON.stringify({ did: podDid }),
655
+ signal: controller.signal,
656
+ },
657
+ 'indexer-redirected',
658
+ );
659
+ await answer.body?.cancel().catch(() => undefined);
660
+ if (answer.ok)
661
+ ctx.logger.log(`${POD_TAG} indexer asked to index ${podDid}`);
662
+ else
663
+ ctx.logger.warn(
664
+ `${POD_TAG} indexer refused to index ${podDid} (HTTP ${answer.status}); its chain poller indexes it later`,
665
+ );
666
+ } catch (error) {
667
+ ctx.logger.warn(
668
+ `${POD_TAG} indexer request for ${podDid} failed (${error instanceof Error ? error.name : 'unknown'}); its chain poller indexes it later`,
669
+ );
670
+ } finally {
671
+ clearTimeout(timer);
672
+ ctx.abortSignal.removeEventListener('abort', onAbort);
673
+ }
674
+ }
675
+
676
+ /* ── the gateway ──────────────────────────────────────────────────────── */
677
+
678
+ /** What the user approves: what the POD is, with its exact public name and description. */
679
+ function podSummary(
680
+ network: Network,
681
+ address: string,
682
+ profile: PodProfile,
683
+ ): string {
684
+ return `Create the POD on ${network}, owned by you (${address}). Its public profile: name "${profile.name}"; description ${profile.description ? `"${profile.description}"` : '(none)'}.`;
685
+ }
686
+
687
+ /**
688
+ * The bundled `ChainGateway`: a POD created as one `MsgCreateEntity` signed
689
+ * by the user's wallet, then its domain card (see the module comment).
690
+ * `deps` exists for tests.
691
+ */
692
+ export function createIxoChainGateway(
693
+ deps: IxoChainGatewayDeps = {},
694
+ ): ChainGateway {
695
+ // Looked up per call: workerd rejects `fetch` invoked with a foreign
696
+ // `this`, and a test may stub the global after the gateway is built.
697
+ const fetchImpl: typeof fetch =
698
+ deps.fetch ?? ((input, init) => globalThis.fetch(input, init));
699
+ const now = deps.now ?? (() => new Date());
700
+ const wait = deps.sleep ?? sleep;
701
+ const io = { fetch: fetchImpl, sleep: deps.sleep };
702
+ const confirmBudgetMs = deps.confirmBudgetMs ?? CONFIRM_BUDGET_MS;
703
+ const pollMs = deps.pollIntervalMs ?? CONFIRM_POLL_MS;
704
+ const prepareBudgetMs = deps.prepareBudgetMs ?? PREPARE_BUDGET_MS;
705
+ const media: MatrixMediaDeps = {
706
+ fetch: fetchImpl,
707
+ requestTimeoutMs: deps.homeserverTimeoutMs ?? HOMESERVER_TIMEOUT_MS,
708
+ credentialsTimeoutMs: deps.credentialsTimeoutMs ?? CREDENTIALS_TIMEOUT_MS,
709
+ };
710
+ const notAnAccount = refuse(
711
+ 'Your identity is not an IXO account (did:ixo:ixo1…), so a POD cannot be created in your name.',
712
+ false,
713
+ );
714
+
715
+ return {
716
+ async preparePodBatch({ network, profile }, ctx) {
717
+ const did = ctx.user.did;
718
+ const address = ixoAddressOf(did);
719
+ if (!address) return notAnAccount;
720
+ const deadline = Date.now() + prepareBudgetMs;
721
+ const owner = await ownerPublicKey(
722
+ chainCall(ctx, network, io, deadline),
723
+ { did, address },
724
+ );
725
+ if (!owner.ok) return owner;
726
+ // Reserved and hashed as these exact bytes: the proof is what a reader
727
+ // checks the downloaded profile against.
728
+ const profileText = JSON.stringify(podProfileDocument(profile));
729
+ const reserved = await reservePublicDocument(
730
+ ctx,
731
+ {
732
+ text: profileText,
733
+ filename: 'profile.json',
734
+ contentType: 'application/ld+json',
735
+ },
736
+ media,
737
+ deadline,
738
+ );
739
+ if (!reserved.ok) return reserved;
740
+ const start = now();
741
+ const message: ITrxMsg = {
742
+ typeUrl: CREATE_ENTITY,
743
+ value: {
744
+ entityType: POD_ENTITY_TYPE,
745
+ entityStatus: 0,
746
+ controller: [did],
747
+ context: [],
748
+ verification: ownerVerifications({
749
+ did,
750
+ address,
751
+ publicKey: owner.publicKey,
752
+ }),
753
+ service: [],
754
+ accordedRight: [],
755
+ linkedResource: [
756
+ {
757
+ id: PROFILE_RESOURCE_ID,
758
+ type: 'Settings',
759
+ description: 'Profile',
760
+ mediaType: 'application/json',
761
+ serviceEndpoint: reserved.url,
762
+ proof: documentProof(profileText),
763
+ encrypted: 'false',
764
+ right: '',
765
+ },
766
+ ],
767
+ linkedEntity: [],
768
+ linkedClaim: [],
769
+ startDate: start.toISOString(),
770
+ endDate: new Date(start.getTime() + ENTITY_LIFETIME_MS).toISOString(),
771
+ relayerNode: relayerNodeDidFor(ctx, network),
772
+ ownerDid: did,
773
+ ownerAddress: address,
774
+ },
775
+ };
776
+ return {
777
+ ok: true,
778
+ messages: [message],
779
+ summary: podSummary(network, address, profile),
780
+ documents: reserved.pending ? [reserved.pending] : [],
781
+ };
782
+ },
783
+
784
+ publishDocuments({ documents }, ctx) {
785
+ return publishPendingDocuments(
786
+ ctx,
787
+ documents,
788
+ media,
789
+ Date.now() + PUBLISH_BUDGET_MS,
790
+ );
791
+ },
792
+
793
+ async confirmPodCreation({ txHash, network, sent }, ctx) {
794
+ const started = Date.now();
795
+ const call = chainCall(ctx, network, io, started + confirmBudgetMs);
796
+ const owner = { did: ctx.user.did, address: ixoAddressOf(ctx.user.did) };
797
+ let lastFailure: Failure | null = null;
798
+ while (true) {
799
+ const read = await restJson(
800
+ call,
801
+ `/cosmos/tx/v1beta1/txs/${txHash}`,
802
+ txSchema,
803
+ { label: 'POD transaction', tag: POD_TAG, noneOn: [404] },
804
+ );
805
+ if (read.ok && !('absent' in read)) {
806
+ if (
807
+ read.value.tx_response.txhash.toUpperCase() !== txHash.toUpperCase()
808
+ )
809
+ return {
810
+ status: 'unavailable',
811
+ retryable: false,
812
+ message:
813
+ 'The chain answered with a different transaction than the one asked for.',
814
+ };
815
+ return podOfTransaction(read.value, owner, sent, network);
816
+ }
817
+ if (!read.ok && !read.failure.retryable)
818
+ return {
819
+ status: 'unavailable',
820
+ retryable: false,
821
+ message: `Could not read the transaction from the chain: ${read.failure.detail}`,
822
+ };
823
+ // Not indexed yet, or the node is briefly unavailable: ask again
824
+ // while the budget leaves room for a wait and one more read.
825
+ lastFailure = read.ok ? null : read.failure;
826
+ if (call.deadline - Date.now() < pollMs + MIN_ATTEMPT_MS) break;
827
+ await wait(pollMs, ctx.abortSignal);
828
+ }
829
+ const seconds = Math.round((Date.now() - started) / 1000);
830
+ return lastFailure === null
831
+ ? {
832
+ status: 'pending',
833
+ retryable: true,
834
+ message: `The chain has no transaction ${txHash} after ${seconds} s: it may not be in a block yet. Call confirm_pod_creation again in a moment with the same hash.`,
835
+ }
836
+ : {
837
+ status: 'unavailable',
838
+ retryable: true,
839
+ message: `The chain could not be read for ${seconds} s (${lastFailure.detail}). Call confirm_pod_creation again in a moment with the same hash.`,
840
+ };
841
+ },
842
+
843
+ async prepareDomainCard({ network, podDid, profile }, ctx) {
844
+ const address = ixoAddressOf(ctx.user.did);
845
+ if (!address) return notAnAccount;
846
+ const cardText = JSON.stringify(
847
+ podDomainCardDocument({
848
+ podDid,
849
+ ownerDid: ctx.user.did,
850
+ profile,
851
+ validFrom: now().toISOString(),
852
+ }),
853
+ );
854
+ const reserved = await reservePublicDocument(
855
+ ctx,
856
+ {
857
+ text: cardText,
858
+ filename: 'domainCard.json',
859
+ contentType: 'application/ld+json',
860
+ },
861
+ media,
862
+ Date.now() + prepareBudgetMs,
863
+ );
864
+ if (!reserved.ok) return reserved;
865
+ return {
866
+ ok: true,
867
+ messages: [
868
+ {
869
+ typeUrl: ADD_LINKED_RESOURCE,
870
+ value: {
871
+ id: podDid,
872
+ linkedResource: {
873
+ id: '{id}#dmn',
874
+ type: 'domainCard',
875
+ description: 'Domain Card',
876
+ mediaType: 'application/json',
877
+ serviceEndpoint: reserved.url,
878
+ proof: documentProof(cardText),
879
+ encrypted: 'false',
880
+ right: '',
881
+ },
882
+ signer: address,
883
+ },
884
+ },
885
+ ],
886
+ summary: `Add the public domain card of your POD ${podDid} on ${network}, so the Portal lists it and search finds it: name "${profile.name}"; description ${profile.description ? `"${profile.description}"` : '(none)'}.`,
887
+ documents: reserved.pending ? [reserved.pending] : [],
888
+ };
889
+ },
890
+
891
+ async requestIndexing({ network, podDid }, ctx) {
892
+ throwIfAborted(ctx.abortSignal);
893
+ await requestIndexing(
894
+ ctx,
895
+ domainIndexerUrlFor(ctx, network),
896
+ podDid,
897
+ fetchImpl,
898
+ );
899
+ },
900
+ };
901
+ }