@ixo/oracle-runtime-workers 0.31.0 → 0.32.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 +4 -4
- package/src/core/middlewares/tool-repetition-guard.ts +6 -3
- package/src/core/middlewares/turn-boundary.ts +6 -1
- package/src/core/tool-not-run.ts +30 -0
- package/src/core/wrap-plugin-tool.ts +9 -1
- package/src/do/user-oracle-do.ts +78 -15
- package/src/hub-actions/chain-tx-refusals.ts +217 -0
- package/src/hub-actions/client.ts +91 -27
- package/src/hub-actions/request.ts +32 -25
- package/src/hub-actions/transfer.ts +76 -26
- package/src/plugin-api/types.ts +59 -18
- package/src/plugins/hub-actions/chain-tx-catalog.ts +1239 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +394 -215
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +1 -1
- package/src/plugins/ixo-chain/kinds/chain/index.ts +2 -0
- package/src/plugins/ixo-chain/kinds/chain/validators.ts +151 -0
- package/src/plugins/yoma/engine.ts +107 -55
- package/src/plugins/yoma/submission.ts +3 -6
- package/src/plugins/yoma/yoma.plugin.ts +12 -3
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHAIN_TX_CLAIM_ROLES,
|
|
3
|
+
CHAIN_TX_COLLECTION_STATES,
|
|
4
|
+
CHAIN_TX_DISPUTE_REASONS,
|
|
5
|
+
CHAIN_TX_ENTITY_PERMISSIONS,
|
|
6
|
+
CHAIN_TX_MAX_CLAIM_PERMISSION,
|
|
7
|
+
CHAIN_TX_MAX_MESSAGES,
|
|
8
|
+
CHAIN_TX_MAX_PAYMENT_COINS,
|
|
9
|
+
CHAIN_TX_MAX_SECONDS,
|
|
10
|
+
CHAIN_TX_MAX_TOKEN_LINES,
|
|
11
|
+
CHAIN_TX_RESERVATION_MODES,
|
|
12
|
+
CHAIN_TX_VOTE_OPTIONS,
|
|
13
|
+
DENOM,
|
|
14
|
+
sanitizePublishedText,
|
|
15
|
+
type ChainTxMessageType,
|
|
16
|
+
} from '@ixo/action-protocol';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import type { ChainTxMessageInput } from '../../plugin-api/types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The messages of one chain transaction (`ixo.chain.tx.v1`), as the model
|
|
22
|
+
* writes them. The shapes are the protocol's minus the owner's identity
|
|
23
|
+
* (`ChainTxOwnerField`: a claim's `agentDid`, an evaluation's
|
|
24
|
+
* `agentAddress`, a permission's `creatorDid`, …), which the runtime fills,
|
|
25
|
+
* as it fills the chain id, network and signer; a bank or stake step names
|
|
26
|
+
* a display amount, which the owner object converts to base units. The
|
|
27
|
+
* protocol parses the whole operation again before it reaches the Hub. The
|
|
28
|
+
* rules match `@ixo/action-protocol`'s (ids, address length, DID escapes,
|
|
29
|
+
* dates, links, text characters, cross-field rules), so a message the Hub
|
|
30
|
+
* would refuse is refused here, before any Hub call, with the field named.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A 20-byte bech32 account address: `ixo1` and 38 characters (a 32-byte contract address is longer). */
|
|
34
|
+
const IXO_ADDRESS = /^ixo1[023456789acdefghjklmnpqrstuvwxyz]{38}$/;
|
|
35
|
+
/** A validator operator address: `ixovaloper1` and 38 characters. */
|
|
36
|
+
const VALOPER_ADDRESS = /^ixovaloper1[023456789acdefghjklmnpqrstuvwxyz]{38}$/;
|
|
37
|
+
const BASE_UNITS = /^[1-9][0-9]{0,77}$/;
|
|
38
|
+
/** DID segments: `[A-Za-z0-9._-]` or a `%xx` escape, joined by `:` (the chain's x/iid rule). */
|
|
39
|
+
const DID_TAIL =
|
|
40
|
+
'(?:(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})*:)*(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+';
|
|
41
|
+
const ANY_DID = new RegExp(`^did:[a-z0-9]+:${DID_TAIL}$`);
|
|
42
|
+
const IXO_DID = new RegExp(`^did:ixo:${DID_TAIL}$`);
|
|
43
|
+
const DID_MAX = 200;
|
|
44
|
+
/** x/entity ids: md5 hex of `<nftContract>/<sequence>`. */
|
|
45
|
+
const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
|
|
46
|
+
/** Claim, collection, token and proof ids: no spaces, quotes or markup. */
|
|
47
|
+
const ID_CHARS = /^[A-Za-z0-9:._/-]+$/;
|
|
48
|
+
const ID_RULE = 'Only letters, digits and : . _ / - (copied exactly).';
|
|
49
|
+
/** A uint64 in decimal without leading zeros (bounded below). */
|
|
50
|
+
const UINT64_TEXT = /^(0|[1-9][0-9]{0,19})$/;
|
|
51
|
+
const UINT64_MAX = 18446744073709551615n;
|
|
52
|
+
/** RFC 3339 with `Z` or an offset, at most milliseconds. */
|
|
53
|
+
const INSTANT =
|
|
54
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/;
|
|
55
|
+
/** The latest instant a protobuf Timestamp holds the way the Hub encodes it. */
|
|
56
|
+
const INSTANT_MAX_MS = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
|
|
57
|
+
const CLAIM_COUNT = /^[1-9][0-9]{0,9}$/;
|
|
58
|
+
const JURISDICTION =
|
|
59
|
+
/^(?:Global|[A-Z]{2}(?:-[A-Za-z0-9]{1,3}(?: [A-Za-z0-9]{1,64})?)?)$/;
|
|
60
|
+
const EVIDENCE_TYPE = /^[A-Za-z0-9][A-Za-z0-9.+/_-]{0,99}$/;
|
|
61
|
+
/** https: a host and no user info (`https://a@b` reads as `a` but goes to `b`). */
|
|
62
|
+
const HTTPS_URL = /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s\\]*)?$/;
|
|
63
|
+
const IPFS_URL = /^ipfs:\/\/[A-Za-z0-9]+(?:\/[^\s\\]*)?$/;
|
|
64
|
+
const URL_MAX = 300;
|
|
65
|
+
const ENTITY_TYPE = /^[a-z][a-z0-9_-]{0,31}(?:\/[a-z][a-z0-9_-]{0,31})?$/;
|
|
66
|
+
const ACCOUNT_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9 _-]{0,62}[A-Za-z0-9])?$/;
|
|
67
|
+
/**
|
|
68
|
+
* x/iid item ids for an add: Portal writes `{id}#…` literally (the chain
|
|
69
|
+
* stores it as is); the name after `#` may hold `:` (Portal's `#app:<id>`).
|
|
70
|
+
*/
|
|
71
|
+
const ITEM_ID =
|
|
72
|
+
/^(?:\{id\}|did:ixo:[A-Za-z0-9._:%-]{1,160})#[A-Za-z0-9._~:-]{1,64}$/;
|
|
73
|
+
const ITEM_ID_MAX = 233;
|
|
74
|
+
/**
|
|
75
|
+
* The id of an item to remove is whatever the record holds (Portal stored
|
|
76
|
+
* `#api`, `{id}#ixo1…`, ids without `#`): 1..300 characters without
|
|
77
|
+
* whitespace or unsafe characters. Auth Hub reads the record and refuses an
|
|
78
|
+
* id that is not there.
|
|
79
|
+
*/
|
|
80
|
+
const REMOVE_ID_MAX = 300;
|
|
81
|
+
/** `+`: Portal's `application/vnd.ixo.design-pod+json` resource type. */
|
|
82
|
+
const IID_TYPE = /^[A-Za-z][A-Za-z0-9._:/+-]{0,63}$/;
|
|
83
|
+
const MEDIA_TYPE = /^[a-z]+\/[A-Za-z0-9.+-]{1,90}$/;
|
|
84
|
+
const MEDIA_TYPE_MAX = 128;
|
|
85
|
+
/** Mixed case: Portal writes `Governs`. */
|
|
86
|
+
const RELATIONSHIP = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
87
|
+
const CONTEXT_KEY = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
88
|
+
/**
|
|
89
|
+
* A linked entity's service: `""` (none, Portal's default), a service name
|
|
90
|
+
* (`svc1`, `matrix`, `did:cosmos:ixo` as stored on chain) or a reference to
|
|
91
|
+
* one (`{id}#matrix`).
|
|
92
|
+
*/
|
|
93
|
+
const SERVICE_REF =
|
|
94
|
+
/^(?:(?:(?:\{id\}|did:ixo:[A-Za-z0-9._:%-]{1,160})#)?[A-Za-z0-9._~:-]{1,64})?$/;
|
|
95
|
+
/**
|
|
96
|
+
* A linked entity that is not a DID: an ixo account (20-byte) or contract
|
|
97
|
+
* (32-byte) address, or Portal's `{id}#<address>` (a group's core contract).
|
|
98
|
+
*/
|
|
99
|
+
const LINKED_ENTITY_ADDRESS =
|
|
100
|
+
/^(?:\{id\}#)?ixo1(?:[023456789acdefghjklmnpqrstuvwxyz]{38}|[023456789acdefghjklmnpqrstuvwxyz]{58})$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Characters that would let text render as something else: C0 and C1
|
|
104
|
+
* controls, zero-width and direction marks (U+200B–U+200F), line and
|
|
105
|
+
* paragraph separators (U+2028–U+2029), bidi embeddings, overrides and
|
|
106
|
+
* isolates (U+202A–U+202E, U+2066–U+2069), the Arabic letter mark (U+061C)
|
|
107
|
+
* and the byte order mark (U+FEFF). Auth Hub refuses the same set.
|
|
108
|
+
*/
|
|
109
|
+
export function hasUnsafeText(value: string): boolean {
|
|
110
|
+
for (const char of value) {
|
|
111
|
+
const code = char.codePointAt(0) ?? 0;
|
|
112
|
+
if (
|
|
113
|
+
code <= 0x1f ||
|
|
114
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
115
|
+
code === 0x061c ||
|
|
116
|
+
(code >= 0x200b && code <= 0x200f) ||
|
|
117
|
+
(code >= 0x2028 && code <= 0x202e) ||
|
|
118
|
+
(code >= 0x2066 && code <= 0x2069) ||
|
|
119
|
+
code === 0xfeff
|
|
120
|
+
)
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The epoch milliseconds of an RFC 3339 instant that is a real calendar
|
|
128
|
+
* date and, in UTC, between 1970-01-01T00:00:00Z and the end of 9999 (what
|
|
129
|
+
* a protobuf Timestamp holds the way the Hub encodes it); else null.
|
|
130
|
+
*/
|
|
131
|
+
export function instantMs(value: string): number | null {
|
|
132
|
+
const parts = INSTANT.exec(value);
|
|
133
|
+
if (!parts) return null;
|
|
134
|
+
const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] =
|
|
135
|
+
parts;
|
|
136
|
+
const y = Number(year);
|
|
137
|
+
const m = Number(month);
|
|
138
|
+
const leap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
|
|
139
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
140
|
+
if (
|
|
141
|
+
m < 1 ||
|
|
142
|
+
m > 12 ||
|
|
143
|
+
Number(day) < 1 ||
|
|
144
|
+
Number(day) > (days[m - 1] ?? 0) ||
|
|
145
|
+
Number(hour) > 23 ||
|
|
146
|
+
Number(minute) > 59 ||
|
|
147
|
+
Number(second) > 59 ||
|
|
148
|
+
(offsetHour !== undefined &&
|
|
149
|
+
(Number(offsetHour) > 23 || Number(offsetMinute) > 59))
|
|
150
|
+
)
|
|
151
|
+
return null;
|
|
152
|
+
const ms = Date.parse(value);
|
|
153
|
+
return Number.isFinite(ms) && ms >= 0 && ms <= INSTANT_MAX_MS ? ms : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** An https:// link (and an ipfs:// one when allowed) of at most 300 characters. */
|
|
157
|
+
function isLink(value: string, ipfs: boolean): boolean {
|
|
158
|
+
if (value.length > URL_MAX || hasUnsafeText(value)) return false;
|
|
159
|
+
if (ipfs && IPFS_URL.test(value)) return true;
|
|
160
|
+
if (!HTTPS_URL.test(value)) return false;
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(value);
|
|
163
|
+
return (
|
|
164
|
+
url.protocol === 'https:' &&
|
|
165
|
+
url.hostname !== '' &&
|
|
166
|
+
url.username === '' &&
|
|
167
|
+
url.password === ''
|
|
168
|
+
);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Field builders (the formats describe_chain_messages shows) ----
|
|
175
|
+
|
|
176
|
+
const address = (what: string) =>
|
|
177
|
+
z
|
|
178
|
+
.string()
|
|
179
|
+
.regex(
|
|
180
|
+
IXO_ADDRESS,
|
|
181
|
+
'A 20-byte ixo1… account address (42 characters); contract addresses are refused.',
|
|
182
|
+
)
|
|
183
|
+
.describe(`${what} (an ixo1… account address).`);
|
|
184
|
+
const validator = (what: string) =>
|
|
185
|
+
z
|
|
186
|
+
.string()
|
|
187
|
+
.regex(
|
|
188
|
+
VALOPER_ADDRESS,
|
|
189
|
+
'An ixovaloper1… validator address (49 characters), copied exactly from chain_lookup staking.',
|
|
190
|
+
)
|
|
191
|
+
.describe(`${what} (an ixovaloper1… validator address).`);
|
|
192
|
+
const id = (max: number) => z.string().min(1).max(max).regex(ID_CHARS, ID_RULE);
|
|
193
|
+
const baseUnits = (what: string) =>
|
|
194
|
+
z
|
|
195
|
+
.string()
|
|
196
|
+
.regex(BASE_UNITS, 'A whole number of base units above zero.')
|
|
197
|
+
.describe(what);
|
|
198
|
+
const uint64 = (what: string) =>
|
|
199
|
+
z
|
|
200
|
+
.string()
|
|
201
|
+
.regex(UINT64_TEXT, 'Digits only, no leading zeros.')
|
|
202
|
+
// zod runs every check: a value the pattern refused is not parsed here.
|
|
203
|
+
.refine(
|
|
204
|
+
(value) => !UINT64_TEXT.test(value) || BigInt(value) <= UINT64_MAX,
|
|
205
|
+
{
|
|
206
|
+
message: 'At most 18446744073709551615.',
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
.describe(what);
|
|
210
|
+
const ixoDid = (what: string) =>
|
|
211
|
+
z
|
|
212
|
+
.string()
|
|
213
|
+
.max(DID_MAX)
|
|
214
|
+
.regex(IXO_DID, 'A did:ixo:… DID (% only as %xx), copied exactly.')
|
|
215
|
+
.describe(what);
|
|
216
|
+
const entityDid = (what: string) =>
|
|
217
|
+
z
|
|
218
|
+
.string()
|
|
219
|
+
.regex(
|
|
220
|
+
ENTITY_DID,
|
|
221
|
+
'did:ixo:entity: and 32 lowercase hex characters, copied exactly.',
|
|
222
|
+
)
|
|
223
|
+
.describe(what);
|
|
224
|
+
const instant = (what: string) =>
|
|
225
|
+
z
|
|
226
|
+
.string()
|
|
227
|
+
.max(40)
|
|
228
|
+
.regex(
|
|
229
|
+
INSTANT,
|
|
230
|
+
'A date and time in RFC 3339 with Z or an offset, such as 2026-11-01T00:00:00Z or 2026-12-31T23:59:59+03:00.',
|
|
231
|
+
)
|
|
232
|
+
.refine((value) => instantMs(value) !== null, {
|
|
233
|
+
message: 'A real calendar date between 1970 and the end of 9999.',
|
|
234
|
+
})
|
|
235
|
+
.describe(what);
|
|
236
|
+
const plainText = (max: number, what: string) =>
|
|
237
|
+
z
|
|
238
|
+
.string()
|
|
239
|
+
.min(1)
|
|
240
|
+
.max(max)
|
|
241
|
+
.refine((value) => !hasUnsafeText(value) && /\S/.test(value), {
|
|
242
|
+
message:
|
|
243
|
+
'Plain text: not blank, no control, invisible or text-direction characters.',
|
|
244
|
+
})
|
|
245
|
+
.describe(what);
|
|
246
|
+
/**
|
|
247
|
+
* Free text published on chain is exactly what the review page shows:
|
|
248
|
+
* `sanitizePublishedText` (the protocol's own check) leaves it unchanged:
|
|
249
|
+
* no link (a URL with a scheme, `www.`, an email address, or a host
|
|
250
|
+
* followed by a path such as pay.example/now; a file name or a bare domain
|
|
251
|
+
* is fine), no hidden characters (tag characters included), single spaces,
|
|
252
|
+
* none at either end.
|
|
253
|
+
*/
|
|
254
|
+
const SHOWN_AS_IS =
|
|
255
|
+
'Shown exactly as written: no links (web addresses, www., email addresses, or a site name followed by a path), no hidden characters, single spaces and none at either end. File names such as Annual Report.pdf are fine.';
|
|
256
|
+
const shownAsIs = (value: string) => sanitizePublishedText(value) === value;
|
|
257
|
+
const link = (ipfs: boolean, what: string) =>
|
|
258
|
+
z
|
|
259
|
+
.string()
|
|
260
|
+
.min(1)
|
|
261
|
+
.max(URL_MAX)
|
|
262
|
+
.refine((value) => isLink(value, ipfs), {
|
|
263
|
+
message: ipfs
|
|
264
|
+
? 'An https:// link with a host (no user@, spaces or backslashes) or an ipfs://<CID> link, at most 300 characters.'
|
|
265
|
+
: 'An https:// link with a host (no user@, spaces or backslashes), at most 300 characters.',
|
|
266
|
+
})
|
|
267
|
+
.describe(what);
|
|
268
|
+
const claimCount = (what: string) =>
|
|
269
|
+
z
|
|
270
|
+
.string()
|
|
271
|
+
.regex(CLAIM_COUNT, `Digits, 1 to ${CHAIN_TX_MAX_CLAIM_PERMISSION}.`)
|
|
272
|
+
.refine((value) => Number(value) <= CHAIN_TX_MAX_CLAIM_PERMISSION, {
|
|
273
|
+
message: `At most ${CHAIN_TX_MAX_CLAIM_PERMISSION}.`,
|
|
274
|
+
})
|
|
275
|
+
.describe(what);
|
|
276
|
+
const seconds = (what: string) =>
|
|
277
|
+
z.number().int().min(0).max(CHAIN_TX_MAX_SECONDS).describe(what);
|
|
278
|
+
const proof = id(300).describe(
|
|
279
|
+
'The content id (CID) or proof of the stored document, copied exactly.',
|
|
280
|
+
);
|
|
281
|
+
const claimId = id(256).describe('The claim id, exactly as stored.');
|
|
282
|
+
const collectionId = uint64('The claim collection id (digits).');
|
|
283
|
+
const adminAddress = address(
|
|
284
|
+
"The collection's admin account (chain_lookup claim_collection: `admin`)",
|
|
285
|
+
);
|
|
286
|
+
const itemId = (what: string) =>
|
|
287
|
+
z
|
|
288
|
+
.string()
|
|
289
|
+
.max(ITEM_ID_MAX)
|
|
290
|
+
.regex(
|
|
291
|
+
ITEM_ID,
|
|
292
|
+
'`{id}#name` or `did:ixo:…#name` (name: letters, digits and . _ ~ : -).',
|
|
293
|
+
)
|
|
294
|
+
.describe(what);
|
|
295
|
+
const removeId = (what: string) =>
|
|
296
|
+
z
|
|
297
|
+
.string()
|
|
298
|
+
.min(1)
|
|
299
|
+
.max(REMOVE_ID_MAX)
|
|
300
|
+
.refine((value) => !/\s/.test(value) && !hasUnsafeText(value), {
|
|
301
|
+
message:
|
|
302
|
+
'The id exactly as the record shows it: no spaces, control, invisible or text-direction characters.',
|
|
303
|
+
})
|
|
304
|
+
.describe(what);
|
|
305
|
+
/** A linked resource's or claim's proof: "" (app links) or a content id. */
|
|
306
|
+
const linkProof = z
|
|
307
|
+
.string()
|
|
308
|
+
.max(300)
|
|
309
|
+
.refine((value) => value === '' || ID_CHARS.test(value), { message: ID_RULE })
|
|
310
|
+
.describe(
|
|
311
|
+
'The stored document’s content id (CID), copied exactly; "" when it has none.',
|
|
312
|
+
);
|
|
313
|
+
/** A linked resource's or claim's description: up to 300, "" allowed. */
|
|
314
|
+
const linkDescription = (what: string) =>
|
|
315
|
+
z
|
|
316
|
+
.string()
|
|
317
|
+
.max(300)
|
|
318
|
+
.refine((value) => !hasUnsafeText(value) && shownAsIs(value), {
|
|
319
|
+
message: SHOWN_AS_IS,
|
|
320
|
+
})
|
|
321
|
+
.describe(what);
|
|
322
|
+
const linkRight = z
|
|
323
|
+
.string()
|
|
324
|
+
.max(300)
|
|
325
|
+
.refine(
|
|
326
|
+
(value) =>
|
|
327
|
+
value === '' ||
|
|
328
|
+
(!hasUnsafeText(value) && /\S/.test(value) && shownAsIs(value)),
|
|
329
|
+
{ message: SHOWN_AS_IS },
|
|
330
|
+
)
|
|
331
|
+
.optional()
|
|
332
|
+
.describe('Optional: the rights that apply to it.');
|
|
333
|
+
const iidType = (what: string) =>
|
|
334
|
+
z
|
|
335
|
+
.string()
|
|
336
|
+
.max(64)
|
|
337
|
+
.regex(
|
|
338
|
+
IID_TYPE,
|
|
339
|
+
'Starts with a letter; letters, digits and . _ : / + - (up to 64).',
|
|
340
|
+
)
|
|
341
|
+
.describe(what);
|
|
342
|
+
/** A display amount: a plain decimal in the token's units, as the user said it. */
|
|
343
|
+
const DISPLAY_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
|
|
344
|
+
/**
|
|
345
|
+
* A stake's IXO amount: at most 72 whole digits and 6 significant decimals
|
|
346
|
+
* (trailing zeros beyond them carry no value), so the converted amount stays
|
|
347
|
+
* within the protocol's base units (78 digits) and an over-long amount is
|
|
348
|
+
* refused here, as `amount`.
|
|
349
|
+
*/
|
|
350
|
+
const IXO_AMOUNT = /^(0|[1-9][0-9]{0,71})(\.(?=[0-9])[0-9]{0,6}0*)?$/;
|
|
351
|
+
const ixoAmount = z
|
|
352
|
+
.string()
|
|
353
|
+
.max(80)
|
|
354
|
+
.regex(
|
|
355
|
+
IXO_AMOUNT,
|
|
356
|
+
'A plain decimal amount of IXO with at most 6 decimals, such as 100 or 0.5 (never base units).',
|
|
357
|
+
)
|
|
358
|
+
.describe('The amount of IXO as the owner said it (100 = 100 IXO).');
|
|
359
|
+
|
|
360
|
+
/** Payment coins of one leg: base units, each token once. */
|
|
361
|
+
const paymentCoins = z
|
|
362
|
+
.array(
|
|
363
|
+
z.strictObject({
|
|
364
|
+
denom: z
|
|
365
|
+
.string()
|
|
366
|
+
.max(128)
|
|
367
|
+
.regex(DENOM, 'A denom, copied exactly (uixo for IXO).')
|
|
368
|
+
.describe('The token: `uixo` for IXO, else its denom copied exactly.'),
|
|
369
|
+
amountBaseUnits: baseUnits(
|
|
370
|
+
'The amount in base units (1 IXO = 1000000 uixo).',
|
|
371
|
+
),
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
374
|
+
.max(CHAIN_TX_MAX_PAYMENT_COINS)
|
|
375
|
+
.superRefine((coins, ctx) => {
|
|
376
|
+
const seen = new Set<string>();
|
|
377
|
+
coins.forEach((coin, index) => {
|
|
378
|
+
if (seen.has(coin.denom))
|
|
379
|
+
ctx.addIssue({
|
|
380
|
+
code: 'custom',
|
|
381
|
+
path: [index, 'denom'],
|
|
382
|
+
message: 'Each token once per payment.',
|
|
383
|
+
});
|
|
384
|
+
seen.add(coin.denom);
|
|
385
|
+
});
|
|
386
|
+
})
|
|
387
|
+
.describe('Up to 4 coins, each token once; an empty list pays nothing.');
|
|
388
|
+
const payAfterSeconds = seconds(
|
|
389
|
+
'Optional: the payment can be collected only this many seconds later (0 or absent: paid at once).',
|
|
390
|
+
).optional();
|
|
391
|
+
const paymentLeg = (what: string) =>
|
|
392
|
+
z
|
|
393
|
+
.strictObject({ amount: paymentCoins, payAfterSeconds })
|
|
394
|
+
.optional()
|
|
395
|
+
.describe(what);
|
|
396
|
+
const payments = z
|
|
397
|
+
.strictObject({
|
|
398
|
+
submission: paymentLeg('Paid to the filer when a claim is filed.'),
|
|
399
|
+
evaluation: paymentLeg('Paid to the checker for each check.'),
|
|
400
|
+
approval: z
|
|
401
|
+
.strictObject({
|
|
402
|
+
amount: paymentCoins,
|
|
403
|
+
payAfterSeconds,
|
|
404
|
+
networkSplit: z
|
|
405
|
+
.boolean()
|
|
406
|
+
.optional()
|
|
407
|
+
.describe(
|
|
408
|
+
'true: the payment is shared with the network and the oracle (left out on a change: kept).',
|
|
409
|
+
),
|
|
410
|
+
})
|
|
411
|
+
.optional()
|
|
412
|
+
.describe('Paid to the filer when a claim is approved.'),
|
|
413
|
+
rejection: paymentLeg('Paid to the filer when a claim is rejected.'),
|
|
414
|
+
})
|
|
415
|
+
.describe(
|
|
416
|
+
'What the collection pays. A new collection: a leg left out pays nothing. A change: a leg left out keeps what it pays now, and `amount: []` stops it.',
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
/** Refuses an end at or before a given start, on `endDate` (the protocol's rule). */
|
|
420
|
+
function endAfterStart(
|
|
421
|
+
dates: { startDate?: string; endDate?: string | null },
|
|
422
|
+
ctx: z.RefinementCtx,
|
|
423
|
+
): void {
|
|
424
|
+
if (typeof dates.startDate !== 'string' || typeof dates.endDate !== 'string')
|
|
425
|
+
return;
|
|
426
|
+
const start = instantMs(dates.startDate);
|
|
427
|
+
const end = instantMs(dates.endDate);
|
|
428
|
+
if (start !== null && end !== null && end <= start)
|
|
429
|
+
ctx.addIssue({
|
|
430
|
+
code: 'custom',
|
|
431
|
+
path: ['endDate'],
|
|
432
|
+
message: 'The end must be after the start.',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** At least one of the dates; the protocol names `startDate` when neither is given. */
|
|
437
|
+
function someDate(
|
|
438
|
+
dates: { startDate?: string; endDate?: string | null },
|
|
439
|
+
ctx: z.RefinementCtx,
|
|
440
|
+
): void {
|
|
441
|
+
if (dates.startDate === undefined && dates.endDate === undefined)
|
|
442
|
+
ctx.addIssue({
|
|
443
|
+
code: 'custom',
|
|
444
|
+
path: ['startDate'],
|
|
445
|
+
message: 'Give startDate, endDate or both.',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The claim-permission options of a role: `reservationSeconds` only for
|
|
451
|
+
* `file-claims`, `decideBefore` only for `check-claims`.
|
|
452
|
+
*/
|
|
453
|
+
function roleOptions(
|
|
454
|
+
message: {
|
|
455
|
+
reservationSeconds?: number;
|
|
456
|
+
decideBefore?: string;
|
|
457
|
+
},
|
|
458
|
+
role: string,
|
|
459
|
+
ctx: z.RefinementCtx,
|
|
460
|
+
): void {
|
|
461
|
+
if (message.reservationSeconds !== undefined && role !== 'file-claims')
|
|
462
|
+
ctx.addIssue({
|
|
463
|
+
code: 'custom',
|
|
464
|
+
path: ['reservationSeconds'],
|
|
465
|
+
message: 'Only for file-claims.',
|
|
466
|
+
});
|
|
467
|
+
if (message.decideBefore !== undefined && role !== 'check-claims')
|
|
468
|
+
ctx.addIssue({
|
|
469
|
+
code: 'custom',
|
|
470
|
+
path: ['decideBefore'],
|
|
471
|
+
message: 'Only for check-claims.',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* One chain-transaction message type: the one-line `summary` the request
|
|
477
|
+
* tool's description lists, the full `rules` describe_chain_messages
|
|
478
|
+
* returns (with the fields of `schema`), and the strict `schema` the
|
|
479
|
+
* request handler checks each message against. Adding a message type is
|
|
480
|
+
* one entry here (once `@ixo/action-protocol` and Auth Hub support it).
|
|
481
|
+
*/
|
|
482
|
+
export interface ChainTxCatalogEntry {
|
|
483
|
+
summary: string;
|
|
484
|
+
rules: string;
|
|
485
|
+
schema: z.ZodObject;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Said once for the ten identity-record types. */
|
|
489
|
+
const IID_RULES =
|
|
490
|
+
'Changes the public identity record (`did`) of an entity the owner controls, or the owner’s own record (`did:ixo:` and their ixo1… address). Read the record first with chain_lookup did_document (or entity) to see what is there. The owner’s key must be one of the record’s sign-in keys, which is the case for their own record and for entities they created.';
|
|
491
|
+
const IID_ADD_RULE =
|
|
492
|
+
'A new item’s id is `{id}#name` (Portal’s form; `{id}` stands for the record) or `did:ixo:…#name`.';
|
|
493
|
+
const IID_REMOVE_RULE =
|
|
494
|
+
'Copy the id exactly as the lookup shows it, whatever its form (`#api`, `{id}#ixo1…`): Auth Hub checks it is there before it makes the card.';
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The catalogue. Every `rules` text ends with how to say the step to the
|
|
498
|
+
* owner: plain everyday words, never the message type or chain terms (the
|
|
499
|
+
* review card in Auth Hub shows the exact values).
|
|
500
|
+
*/
|
|
501
|
+
export const CHAIN_TX_CATALOG = {
|
|
502
|
+
'cosmos.bank.send': {
|
|
503
|
+
summary:
|
|
504
|
+
"send coins (IXO or another token the owner holds) from the owner's wallet.",
|
|
505
|
+
rules: [
|
|
506
|
+
"Sends coins from the owner's wallet to `toAddress`, alone (a single coin send) or as a step of a batch.",
|
|
507
|
+
'`amount` is the amount as the user said it, in the token’s display units ("1.5" for 1.5 IXO): a plain decimal, never base units. The runtime looks the token up in the owner’s token list (hub_action_options `transfer.tokens`), converts the amount with the listed `decimals`, and refuses a token not in the list, more digits after the point than the token has, zero, and more than the listed balance (the bank and stake steps of one token together).',
|
|
508
|
+
'`denom` is optional and defaults to uixo (IXO, 6 decimals). For another token copy its `denom` exactly from `transfer.tokens` (never guess an `ibc/…` hash); name the full denom to the owner for anything but IXO, and say when the token is unverified (it lists 0 decimals: its amount is a whole number of base units).',
|
|
509
|
+
'Say it like: “Send 1.5 IXO to Thandi (ixo1qy…lvf0).”',
|
|
510
|
+
].join(' '),
|
|
511
|
+
schema: z.strictObject({
|
|
512
|
+
type: z.literal('cosmos.bank.send'),
|
|
513
|
+
toAddress: address('The recipient'),
|
|
514
|
+
amount: z
|
|
515
|
+
.string()
|
|
516
|
+
.max(80)
|
|
517
|
+
.regex(
|
|
518
|
+
DISPLAY_AMOUNT,
|
|
519
|
+
'A plain decimal amount in the token’s display units, such as 1.5 (never base units).',
|
|
520
|
+
)
|
|
521
|
+
.describe(
|
|
522
|
+
'The amount as the user said it, in the token’s display units (1.5 = 1.5 IXO).',
|
|
523
|
+
),
|
|
524
|
+
denom: z
|
|
525
|
+
.string()
|
|
526
|
+
.regex(DENOM, 'A denom exactly as hub_action_options lists it.')
|
|
527
|
+
.optional()
|
|
528
|
+
.describe(
|
|
529
|
+
'The token’s denom, copied exactly from hub_action_options `transfer.tokens`; leave out for uixo (IXO).',
|
|
530
|
+
),
|
|
531
|
+
}),
|
|
532
|
+
},
|
|
533
|
+
'ixo.claims.submit': {
|
|
534
|
+
summary:
|
|
535
|
+
'file a claim the owner has already created with a claim collection.',
|
|
536
|
+
rules:
|
|
537
|
+
'Files a claim the owner has already created with a claim collection. `claimId` is the stored claim’s id, `collectionId` the collection, `adminAddress` the collection admin’s address. The runtime adds the owner’s DID as the filer. The collection admin must have authorized the owner to submit claims. Collections with member budgets cannot be filed into here (no member address is sent). Set `useIntent` only when the collection works with intents (after an `ixo.claims.claim-intent` reservation). Say it like: “File your claim in the Clean Water collection.”',
|
|
538
|
+
schema: z.strictObject({
|
|
539
|
+
type: z.literal('ixo.claims.submit'),
|
|
540
|
+
collectionId,
|
|
541
|
+
claimId,
|
|
542
|
+
adminAddress: address("The collection admin's address"),
|
|
543
|
+
useIntent: z
|
|
544
|
+
.boolean()
|
|
545
|
+
.optional()
|
|
546
|
+
.describe('true only when the collection pays claims through intents.'),
|
|
547
|
+
}),
|
|
548
|
+
},
|
|
549
|
+
'ixo.claims.evaluate': {
|
|
550
|
+
summary:
|
|
551
|
+
'record the owner’s decision, as evaluator, on someone else’s claim (approve, reject, dispute, invalidate).',
|
|
552
|
+
rules:
|
|
553
|
+
'Records the owner’s decision, as evaluator, on someone else’s claim (`claimId` in `collectionId`). `status` 1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate. It is recorded as the owner’s (the runtime adds their DID and address, never the claimant’s); `oracle` is the evaluator DID recorded on the evaluation (normally the owner’s DID, or the oracle entity DID the collection admin authorized); `adminAddress` is the collection admin’s address; `reason` (a number) and `verificationProof` are optional. The collection admin must have authorized the owner as an evaluator. An approval can pay the claimant. Say it like: “Approve this claim in Clean Water; approving it can release its payment.”',
|
|
554
|
+
schema: z.strictObject({
|
|
555
|
+
type: z.literal('ixo.claims.evaluate'),
|
|
556
|
+
claimId,
|
|
557
|
+
collectionId,
|
|
558
|
+
oracle: z
|
|
559
|
+
.string()
|
|
560
|
+
.max(DID_MAX)
|
|
561
|
+
.regex(ANY_DID, 'A DID (did:<method>:…; % only as %xx).')
|
|
562
|
+
.describe('The evaluator DID recorded on the evaluation (a did:…).'),
|
|
563
|
+
adminAddress: address("The collection admin's address"),
|
|
564
|
+
status: z
|
|
565
|
+
.literal([1, 2, 3, 4])
|
|
566
|
+
.describe(
|
|
567
|
+
'1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate.',
|
|
568
|
+
),
|
|
569
|
+
reason: z
|
|
570
|
+
.number()
|
|
571
|
+
.int()
|
|
572
|
+
.min(0)
|
|
573
|
+
.max(4_294_967_295)
|
|
574
|
+
.optional()
|
|
575
|
+
.describe("The evaluator's reason code, when there is one."),
|
|
576
|
+
verificationProof: id(300)
|
|
577
|
+
.optional()
|
|
578
|
+
.describe(
|
|
579
|
+
'The evaluation proof (a credential CID), when there is one.',
|
|
580
|
+
),
|
|
581
|
+
}),
|
|
582
|
+
},
|
|
583
|
+
'ixo.token.transfer': {
|
|
584
|
+
summary:
|
|
585
|
+
"move IXO impact tokens (1155-style token ids, whole amounts) from the owner's wallet.",
|
|
586
|
+
rules: `Moves IXO impact tokens (1155-style: each line a token \`id\` of 32 hex characters and a whole \`amount\`, 1 to ${CHAIN_TX_MAX_TOKEN_LINES} lines) from the owner’s wallet to \`recipient\`. Impact tokens have no decimals: an amount is a whole number of tokens. All tokens in one message must come from one token contract. These are not the IXO coin: coins go through \`cosmos.bank.send\`. Say it like: “Give 3 of your carbon credits to Thandi (ixo1qy…lvf0).”`,
|
|
587
|
+
schema: z.strictObject({
|
|
588
|
+
type: z.literal('ixo.token.transfer'),
|
|
589
|
+
recipient: address('The recipient'),
|
|
590
|
+
tokens: z
|
|
591
|
+
.array(
|
|
592
|
+
z.strictObject({
|
|
593
|
+
id: id(128).describe(
|
|
594
|
+
'The impact token id (32 hex characters), exactly as listed.',
|
|
595
|
+
),
|
|
596
|
+
amount: baseUnits('A whole number of tokens above zero.'),
|
|
597
|
+
}),
|
|
598
|
+
)
|
|
599
|
+
.min(1)
|
|
600
|
+
.max(CHAIN_TX_MAX_TOKEN_LINES),
|
|
601
|
+
}),
|
|
602
|
+
},
|
|
603
|
+
'ixo.token.retire': {
|
|
604
|
+
summary:
|
|
605
|
+
'use up (retire) impact tokens the owner holds, for good, for example to offset emissions.',
|
|
606
|
+
rules: `Uses up impact tokens the owner holds, for good: retired tokens can never be sold, moved or used again, and nothing undoes it. Never retire without the owner clearly asking for it. Ask which tokens and how many, where it counts (\`jurisdiction\`: a two-letter country code such as \`KE\`, optionally with a region and postal code such as \`KE-30\` or \`ZA-WC 7700\`, or \`Global\` for everywhere) and why, in their words (\`reason\`, up to 200 characters, for example \`offset\`; written on chain exactly as given, so no links (web or email addresses) and no hidden characters). Find the token ids with chain_lookup token_balances: each line is a token \`id\` and a whole \`amount\` (impact tokens have no decimals), 1 to ${CHAIN_TX_MAX_TOKEN_LINES} lines, all of one kind of token. Say it like: “Retire 10 of your carbon credits for good, to offset your 2026 travel. This can’t be undone.”`,
|
|
607
|
+
schema: z.strictObject({
|
|
608
|
+
type: z.literal('ixo.token.retire'),
|
|
609
|
+
tokens: z
|
|
610
|
+
.array(
|
|
611
|
+
z.strictObject({
|
|
612
|
+
id: id(128).describe('The impact token id, exactly as listed.'),
|
|
613
|
+
amount: baseUnits('A whole number of tokens above zero.'),
|
|
614
|
+
}),
|
|
615
|
+
)
|
|
616
|
+
.min(1)
|
|
617
|
+
.max(CHAIN_TX_MAX_TOKEN_LINES),
|
|
618
|
+
jurisdiction: z
|
|
619
|
+
.string()
|
|
620
|
+
.max(71)
|
|
621
|
+
.regex(
|
|
622
|
+
JURISDICTION,
|
|
623
|
+
'Global, or a two-letter country code with an optional region and postal code (KE, KE-30, ZA-WC 7700).',
|
|
624
|
+
)
|
|
625
|
+
.describe('Where the retirement counts: a country code or Global.'),
|
|
626
|
+
reason: plainText(200, 'Why, in the owner’s words (e.g. offset).').refine(
|
|
627
|
+
shownAsIs,
|
|
628
|
+
{ message: SHOWN_AS_IS },
|
|
629
|
+
),
|
|
630
|
+
}),
|
|
631
|
+
},
|
|
632
|
+
'cosmos.staking.delegate': {
|
|
633
|
+
summary: "stake the owner's IXO with a validator so it earns rewards.",
|
|
634
|
+
rules:
|
|
635
|
+
'Stakes IXO from the owner’s wallet with a validator (one of the operators that run the network) so it earns rewards; the IXO stays theirs. `amount` is IXO as the owner says it ("100", "0.5"), never base units: the runtime converts it and checks it against their IXO balance. Ask how much and with which validator. The owner names a validator in their own words: find it with chain_lookup validators (`name`: part of its name; it lists active validators with their commission) and copy its `validator` address into `validatorAddress`; when several match, or none is named, show a few by name and commission and let the owner choose. Prefer an active one. Getting staked IXO back takes a waiting period set by the network, which the review card shows, so never stake all of it: leave some IXO for fees. Staking with a validator also pays out the rewards already earned with it. Say it like: “Stake 100 IXO with Alex’s validator. It keeps earning rewards, and the review shows how long getting it back takes.”',
|
|
636
|
+
schema: z.strictObject({
|
|
637
|
+
type: z.literal('cosmos.staking.delegate'),
|
|
638
|
+
validatorAddress: validator('The validator to stake with'),
|
|
639
|
+
amount: ixoAmount,
|
|
640
|
+
}),
|
|
641
|
+
},
|
|
642
|
+
'cosmos.staking.undelegate': {
|
|
643
|
+
summary:
|
|
644
|
+
'unstake IXO from a validator; it comes back to the wallet after a waiting period.',
|
|
645
|
+
rules:
|
|
646
|
+
'Stops staking IXO with a validator. It comes back to the owner’s wallet after a waiting period set by the network (the review card shows how long and when) and earns nothing in between. See what they have staked, and with whom, with chain_lookup staking (copy `validatorAddress` from there); ask how much (`amount` in IXO as the owner says it, never base units and never more than they have staked there). Rewards waiting with that validator are paid out at the same time. The network limits how many unstakes from one validator can be waiting at once; when Auth Hub says the limit is reached, tell the owner to wait for one to finish. Say it like: “Unstake 50 IXO from Alex’s validator. It comes back to your wallet after a waiting period (the review shows when) and earns nothing until then.”',
|
|
647
|
+
schema: z.strictObject({
|
|
648
|
+
type: z.literal('cosmos.staking.undelegate'),
|
|
649
|
+
validatorAddress: validator('The validator to unstake from'),
|
|
650
|
+
amount: ixoAmount,
|
|
651
|
+
}),
|
|
652
|
+
},
|
|
653
|
+
'cosmos.staking.redelegate': {
|
|
654
|
+
summary:
|
|
655
|
+
'move staked IXO from one validator to another, right away and without waiting.',
|
|
656
|
+
rules:
|
|
657
|
+
'Moves IXO the owner has staked from one validator (`fromValidatorAddress`) to another (`toValidatorAddress`, a different one) at once; it keeps earning rewards and nothing passes through the wallet. Use chain_lookup staking for what they have staked and with whom, and chain_lookup validators (`name`) to find the validator the owner names for the new one; ask how much (`amount` in IXO as the owner says it, never more than they have staked with the first validator). Stake moved this way can’t be moved again for a while (a waiting period set by the network, which the review card shows). Rewards waiting with both validators are paid out. Say it like: “Move 50 IXO of your stake from Alex’s validator to Bo’s. It keeps earning rewards; you can’t move it again for a while.”',
|
|
658
|
+
schema: z
|
|
659
|
+
.strictObject({
|
|
660
|
+
type: z.literal('cosmos.staking.redelegate'),
|
|
661
|
+
fromValidatorAddress: validator('The validator the stake is with now'),
|
|
662
|
+
toValidatorAddress: validator('The validator it moves to'),
|
|
663
|
+
amount: ixoAmount,
|
|
664
|
+
})
|
|
665
|
+
.superRefine((message, ctx) => {
|
|
666
|
+
if (message.toValidatorAddress === message.fromValidatorAddress)
|
|
667
|
+
ctx.addIssue({
|
|
668
|
+
code: 'custom',
|
|
669
|
+
path: ['toValidatorAddress'],
|
|
670
|
+
message: 'A different validator than fromValidatorAddress.',
|
|
671
|
+
});
|
|
672
|
+
}),
|
|
673
|
+
},
|
|
674
|
+
'cosmos.distribution.withdraw-rewards': {
|
|
675
|
+
summary:
|
|
676
|
+
'claim the staking rewards the owner has earned with a validator (one message per validator).',
|
|
677
|
+
rules: `Moves the rewards the owner earned by staking with one validator into their wallet (or to the account they set to receive their rewards). One message per validator, up to ${CHAIN_TX_MAX_MESSAGES} in one card: use chain_lookup staking to see which validators have rewards and leave out those with none (Auth Hub refuses a validator with nothing to claim). Staking, unstaking or moving stake with a validator already pays its rewards out. Say it like: “Claim about 12.5 IXO of staking rewards from Alex’s validator.”`,
|
|
678
|
+
schema: z.strictObject({
|
|
679
|
+
type: z.literal('cosmos.distribution.withdraw-rewards'),
|
|
680
|
+
validatorAddress: validator('The validator the rewards were earned with'),
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
'cosmos.gov.vote': {
|
|
684
|
+
summary:
|
|
685
|
+
'vote yes, no, abstain or no-with-veto on a network proposal that is open for voting.',
|
|
686
|
+
rules:
|
|
687
|
+
'Records the owner’s vote on a network proposal that is open for voting. Find open proposals, their number (`proposalId`) and title with chain_lookup gov_proposals, and tell the owner the title and when voting closes before asking how they vote. `option`: `yes`, `no`, `abstain` (counts toward the turnout without taking a side) or `no_with_veto` (no, and they think it should never have been proposed). Only IXO they have staked gives the vote weight. Voting again before it closes replaces this vote. Say it like: “Vote Yes on proposal 506, ‘Lower the fees’. You can change your vote until 31 October.”',
|
|
688
|
+
schema: z.strictObject({
|
|
689
|
+
type: z.literal('cosmos.gov.vote'),
|
|
690
|
+
proposalId: z
|
|
691
|
+
.string()
|
|
692
|
+
.regex(/^[1-9][0-9]{0,19}$/, 'The proposal number: digits, from 1.')
|
|
693
|
+
.refine(
|
|
694
|
+
(value) => !UINT64_TEXT.test(value) || BigInt(value) <= UINT64_MAX,
|
|
695
|
+
{
|
|
696
|
+
message: 'At most 18446744073709551615.',
|
|
697
|
+
},
|
|
698
|
+
)
|
|
699
|
+
.describe('The proposal number (chain_lookup gov_proposals).'),
|
|
700
|
+
option: z
|
|
701
|
+
.literal([...CHAIN_TX_VOTE_OPTIONS])
|
|
702
|
+
.describe('yes, no, abstain or no_with_veto.'),
|
|
703
|
+
}),
|
|
704
|
+
},
|
|
705
|
+
'ixo.claims.claim-intent': {
|
|
706
|
+
summary:
|
|
707
|
+
"reserve a collection's payment before filing a claim in it (only where reservations are used).",
|
|
708
|
+
rules:
|
|
709
|
+
'Sets aside a claim collection’s payment for a claim the owner is about to file, for a limited time; nothing leaves their wallet. Use it only when the collection allows or requires reservations (chain_lookup claim_collection) and the owner is already allowed to file claims there; they can hold one reservation per collection. Afterwards file the claim with `ixo.claims.submit` and `useIntent: true` before the reservation runs out. Collections with member budgets (team collections) cannot be reserved in here: the chain needs a member address, which this card does not send, so tell the owner a reservation there cannot be made from here. The runtime adds the owner as the filer. Say it like: “Reserve the payment in the Clean Water collection for the claim you’re about to file.”',
|
|
710
|
+
schema: z.strictObject({
|
|
711
|
+
type: z.literal('ixo.claims.claim-intent'),
|
|
712
|
+
collectionId,
|
|
713
|
+
}),
|
|
714
|
+
},
|
|
715
|
+
'ixo.claims.dispute': {
|
|
716
|
+
summary:
|
|
717
|
+
'open a dispute about a claim (the work, or how it was checked); it may take a deposit.',
|
|
718
|
+
rules:
|
|
719
|
+
'Opens a dispute about a claim, decided by someone independent. Ask what is wrong: `reason` `not_as_asked` (the work isn’t what was asked for), `not_delivered` (nothing was delivered), `check_wrong` (the check of the claim got it wrong) or `other`. `claimId` and `collectionId` come from chain_lookup claim or claims (Auth Hub checks they belong together). `evidence` is the owner’s statement, which must ALREADY be stored: its link (`uri`, https:// or ipfs://), its kind (`type`, for example `eval-engine/v1`), its `proof` (content id) and whether it is `encrypted`. There is no tool here that stores a statement: when the owner has none stored, tell them it has to be saved first (for example in Portal) and do not prepare the card. The collection may take a deposit, returned if they are proven right and lost if not: say so. Until it is decided, the person disputed can’t file or check claims in that collection. Never dispute for someone else; the runtime adds the owner as the one disputing. Say it like: “Dispute this claim in Clean Water because the work isn’t what was asked for. A deposit may apply: you get it back if you’re proven right.”',
|
|
720
|
+
schema: z.strictObject({
|
|
721
|
+
type: z.literal('ixo.claims.dispute'),
|
|
722
|
+
claimId,
|
|
723
|
+
collectionId,
|
|
724
|
+
reason: z
|
|
725
|
+
.literal([...CHAIN_TX_DISPUTE_REASONS])
|
|
726
|
+
.describe('not_as_asked, not_delivered, check_wrong or other.'),
|
|
727
|
+
evidence: z
|
|
728
|
+
.strictObject({
|
|
729
|
+
uri: link(true, 'Where the stored statement is.'),
|
|
730
|
+
type: z
|
|
731
|
+
.string()
|
|
732
|
+
.max(100)
|
|
733
|
+
.regex(
|
|
734
|
+
EVIDENCE_TYPE,
|
|
735
|
+
'Letters, digits and . + / _ - (up to 100), such as eval-engine/v1.',
|
|
736
|
+
)
|
|
737
|
+
.describe('The kind of statement, such as eval-engine/v1.'),
|
|
738
|
+
proof,
|
|
739
|
+
encrypted: z
|
|
740
|
+
.boolean()
|
|
741
|
+
.optional()
|
|
742
|
+
.describe('true when the stored statement is encrypted.'),
|
|
743
|
+
})
|
|
744
|
+
.describe('The already stored statement.'),
|
|
745
|
+
}),
|
|
746
|
+
},
|
|
747
|
+
'ixo.claims.update-collection-state': {
|
|
748
|
+
summary: 'open, pause or close a claim collection the owner manages.',
|
|
749
|
+
rules:
|
|
750
|
+
'Opens, pauses or closes a claim collection. It works with a permission the collection’s entity gave the owner’s address (chain_lookup authz_grants, received, from the collection’s admin account); the entity’s owner gives it with `ixo.entity.grant-account-authz` (`change-collection-state`). `adminAddress` is the collection’s admin account (chain_lookup claim_collection). `state`: `open` (claims can be filed), `paused` or `closed` (both stop new claims; claims already filed can still be checked, and it can be opened again). Ask which. Say it like: “Pause the Clean Water collection so no new claims come in until you open it again.”',
|
|
751
|
+
schema: z.strictObject({
|
|
752
|
+
type: z.literal('ixo.claims.update-collection-state'),
|
|
753
|
+
collectionId,
|
|
754
|
+
adminAddress,
|
|
755
|
+
state: z
|
|
756
|
+
.literal([...CHAIN_TX_COLLECTION_STATES])
|
|
757
|
+
.describe('open, paused or closed.'),
|
|
758
|
+
}),
|
|
759
|
+
},
|
|
760
|
+
'ixo.claims.update-collection-dates': {
|
|
761
|
+
summary: 'change when a claim collection opens and closes.',
|
|
762
|
+
rules:
|
|
763
|
+
'Changes when a claim collection takes claims, with the same kind of permission and `adminAddress` as `ixo.claims.update-collection-state` (permission `change-collection-dates`). Give only the date that changes: `startDate` and/or `endDate`; `endDate: null` removes the end date; a date left out stays as it is. The end must be after the start. Convert the owner’s dates from their time zone (2026-12-31T23:59:59+03:00). Say it like: “Keep the Clean Water collection open until 31 December 2026.”',
|
|
764
|
+
schema: z
|
|
765
|
+
.strictObject({
|
|
766
|
+
type: z.literal('ixo.claims.update-collection-dates'),
|
|
767
|
+
collectionId,
|
|
768
|
+
adminAddress,
|
|
769
|
+
startDate: instant('When it opens; leave out to keep it.').optional(),
|
|
770
|
+
endDate: instant(
|
|
771
|
+
'When it closes; null: no end date; leave out to keep it.',
|
|
772
|
+
)
|
|
773
|
+
.nullable()
|
|
774
|
+
.optional(),
|
|
775
|
+
})
|
|
776
|
+
.superRefine((message, ctx) => {
|
|
777
|
+
someDate(message, ctx);
|
|
778
|
+
endAfterStart(message, ctx);
|
|
779
|
+
}),
|
|
780
|
+
},
|
|
781
|
+
'ixo.claims.update-collection-quota': {
|
|
782
|
+
summary: 'change how many claims a collection takes in total.',
|
|
783
|
+
rules:
|
|
784
|
+
'Sets how many claims a collection takes in total (`maxClaims`, digits; "0" = no limit), with the same kind of permission and `adminAddress` as `ixo.claims.update-collection-state` (permission `change-collection-quota`). It can’t be below the number already filed (chain_lookup claim_collection shows it); setting it equal stops new claims. Say it like: “Let the Clean Water collection take up to 500 claims in total (120 so far).”',
|
|
785
|
+
schema: z.strictObject({
|
|
786
|
+
type: z.literal('ixo.claims.update-collection-quota'),
|
|
787
|
+
collectionId,
|
|
788
|
+
adminAddress,
|
|
789
|
+
maxClaims: uint64('The total number of claims; "0" = no limit.'),
|
|
790
|
+
}),
|
|
791
|
+
},
|
|
792
|
+
'ixo.claims.create-claim-authorization': {
|
|
793
|
+
summary:
|
|
794
|
+
'let another account file or check claims in a collection the owner manages with a permission from its entity.',
|
|
795
|
+
rules: `Lets an account (\`granteeAddress\`) file claims in (\`role: file-claims\`) or check claims of (\`role: check-claims\`) one collection, up to \`maxClaims\` claims (1 to ${CHAIN_TX_MAX_CLAIM_PERMISSION}). It is for people who manage a collection with a permission from its entity (\`authorize-claim-agents\`, chain_lookup authz_grants received); when the owner owns the entity, use \`ixo.entity.grant-account-authz\` instead. \`adminAddress\` is the collection’s admin account (chain_lookup claim_collection). Ask who, which role, how many claims and until when (\`expiresAt\`; left out, an end date the account already has for this kind of permission is kept, else it has none). Optional: \`reservationSeconds\` (file-claims only: how long a reservation holds the payment) and \`decideBefore\` (check-claims only: the date checks must be done by). Never pick a role the owner did not name. The runtime adds the owner as the one who gives it. Say it like: “Let Thandi (ixo1qy…lvf0) file up to 50 claims in the Clean Water collection until 31 December.”`,
|
|
796
|
+
schema: z
|
|
797
|
+
.strictObject({
|
|
798
|
+
type: z.literal('ixo.claims.create-claim-authorization'),
|
|
799
|
+
collectionId,
|
|
800
|
+
adminAddress,
|
|
801
|
+
granteeAddress: address('Who gets the permission'),
|
|
802
|
+
role: z
|
|
803
|
+
.literal([...CHAIN_TX_CLAIM_ROLES])
|
|
804
|
+
.describe('file-claims or check-claims.'),
|
|
805
|
+
maxClaims: claimCount(
|
|
806
|
+
`How many claims, 1 to ${CHAIN_TX_MAX_CLAIM_PERMISSION}.`,
|
|
807
|
+
),
|
|
808
|
+
expiresAt: instant('Optional: when the permission ends.').optional(),
|
|
809
|
+
reservationSeconds: seconds(
|
|
810
|
+
'file-claims only: how long a reservation holds the payment, in seconds.',
|
|
811
|
+
).optional(),
|
|
812
|
+
decideBefore: instant(
|
|
813
|
+
'check-claims only: the date checks must be done by.',
|
|
814
|
+
).optional(),
|
|
815
|
+
})
|
|
816
|
+
.superRefine((message, ctx) => roleOptions(message, message.role, ctx)),
|
|
817
|
+
},
|
|
818
|
+
'ixo.claims.update-collection-payments': {
|
|
819
|
+
summary:
|
|
820
|
+
'change what a collection pays when claims are filed, checked, approved or rejected.',
|
|
821
|
+
rules: `Changes what a claim collection pays, with the same kind of permission and \`adminAddress\` as \`ixo.claims.update-collection-state\` (permission \`change-collection-payments\`). Read the current payments first with chain_lookup claim_collection, and send only the legs that change: a leg left out keeps exactly what it pays now, and a leg with \`amount: []\` stops paying. Legs: \`submission\` (to the filer when a claim is filed), \`evaluation\` (to the checker for each check), \`approval\` (to the filer when approved; \`networkSplit: true\` shares it with the network and the oracle) and \`rejection\` (to the filer when rejected). Each leg has \`amount\`, up to ${CHAIN_TX_MAX_PAYMENT_COINS} coins of different tokens, each \`{denom, amountBaseUnits}\` in base units (IXO: denom \`uixo\`, 1 IXO = 1000000; another token’s denom copied exactly from a lookup, never a guessed \`ibc/…\` denom), and optional \`payAfterSeconds\` (up to ${CHAIN_TX_MAX_SECONDS}: it can be collected only that much later). A leg that pays in contract tokens can’t be replaced here: leave it out and it stays as it is. Say it like: “Pay 50 IXO for each approved claim in the Clean Water collection from now on; the other payments stay as they are.”`,
|
|
822
|
+
schema: z.strictObject({
|
|
823
|
+
type: z.literal('ixo.claims.update-collection-payments'),
|
|
824
|
+
collectionId,
|
|
825
|
+
adminAddress,
|
|
826
|
+
payments,
|
|
827
|
+
}),
|
|
828
|
+
},
|
|
829
|
+
'ixo.claims.create-collection': {
|
|
830
|
+
summary: 'create a claim collection for an entity the owner owns.',
|
|
831
|
+
rules:
|
|
832
|
+
'Creates a claim collection: a place where people file claims of one kind for an entity the owner owns. Ask for the entity (`entityId`: chain_lookup entities or entity), the kind of claim (`protocolId`, the claim template’s did:ixo DID), when it opens (`startDate`) and closes (`endDate`, or null for no end date; always give the key), how many claims in total (`maxClaims`, "0" = no limit), how it starts (`state`: open, paused or closed), whether claims are reserved first (`reservations`: `allow`, `deny` or `required`) and what it pays (`payments`, legs as for `ixo.claims.update-collection-payments`, in base units; on a new collection a leg left out pays nothing). Offer to also let the owner manage the collection from chat later: with their yes, and only then, add `ixo.entity.grant-account-authz` steps to the same card that give the owner’s own address `change-collection-state`, `change-collection-dates`, `change-collection-quota` and `change-collection-payments` (as Portal does). Never add a step the owner did not ask for. A collection can’t be deleted, only closed. The result does not include the new collection’s number: once the owner has approved it and it went through (hub_action_status), find it with chain_lookup claim_collections. Say it like: “Create a claim collection for Clean Water Project, open from 1 November with no end date, paying 50 IXO for each approved claim.”',
|
|
833
|
+
schema: z
|
|
834
|
+
.strictObject({
|
|
835
|
+
type: z.literal('ixo.claims.create-collection'),
|
|
836
|
+
entityId: entityDid('The entity the collection belongs to.'),
|
|
837
|
+
protocolId: ixoDid('The kind of claim: the claim template’s DID.'),
|
|
838
|
+
startDate: instant('When it opens.'),
|
|
839
|
+
endDate: instant('When it closes; null: no end date.').nullable(),
|
|
840
|
+
maxClaims: uint64('The total number of claims; "0" = no limit.'),
|
|
841
|
+
state: z
|
|
842
|
+
.literal([...CHAIN_TX_COLLECTION_STATES])
|
|
843
|
+
.describe('How it starts: open, paused or closed.'),
|
|
844
|
+
reservations: z
|
|
845
|
+
.literal([...CHAIN_TX_RESERVATION_MODES])
|
|
846
|
+
.describe('allow, deny or required.'),
|
|
847
|
+
payments,
|
|
848
|
+
})
|
|
849
|
+
.superRefine(endAfterStart),
|
|
850
|
+
},
|
|
851
|
+
'ixo.entity.create-account': {
|
|
852
|
+
summary: 'add a named account (for funds) to an entity the owner owns.',
|
|
853
|
+
rules:
|
|
854
|
+
'Adds a new, empty account with a name to an entity the owner owns (`entityId`: chain_lookup entities or entity, which also lists the entity’s accounts; a name already used is refused). `name`: letters, digits, spaces, `_` or `-`, up to 64 characters, not `admin`. It can’t be removed later. Say it like: “Add an account called ‘Payouts’ to Clean Water Project.”',
|
|
855
|
+
schema: z.strictObject({
|
|
856
|
+
type: z.literal('ixo.entity.create-account'),
|
|
857
|
+
entityId: entityDid('The entity.'),
|
|
858
|
+
name: z
|
|
859
|
+
.string()
|
|
860
|
+
.max(64)
|
|
861
|
+
.regex(
|
|
862
|
+
ACCOUNT_NAME,
|
|
863
|
+
'Letters, digits, spaces, _ or - (starting and ending with a letter or digit), up to 64.',
|
|
864
|
+
)
|
|
865
|
+
.refine((value) => value.toLowerCase() !== 'admin', {
|
|
866
|
+
message: 'Not admin (every entity already has it).',
|
|
867
|
+
})
|
|
868
|
+
.describe('The new account’s name.'),
|
|
869
|
+
}),
|
|
870
|
+
},
|
|
871
|
+
'ixo.entity.update': {
|
|
872
|
+
summary:
|
|
873
|
+
'change when an entity the owner controls is active (its start and end dates).',
|
|
874
|
+
rules:
|
|
875
|
+
'Changes an entity’s start and/or end date (`startDate`, `endDate`; `endDate: null` clears it); a date left out stays as it is, and so do its status and credentials. The end must be after the start. The owner must control the entity (chain_lookup entity); the runtime adds the owner as the controller. Say it like: “Set Clean Water Project to run until 31 December 2030.”',
|
|
876
|
+
schema: z
|
|
877
|
+
.strictObject({
|
|
878
|
+
type: z.literal('ixo.entity.update'),
|
|
879
|
+
entityId: entityDid('The entity.'),
|
|
880
|
+
startDate: instant('When it starts; leave out to keep it.').optional(),
|
|
881
|
+
endDate: instant('When it ends; null clears it; leave out to keep it.')
|
|
882
|
+
.nullable()
|
|
883
|
+
.optional(),
|
|
884
|
+
})
|
|
885
|
+
.superRefine((message, ctx) => {
|
|
886
|
+
someDate(message, ctx);
|
|
887
|
+
endAfterStart(message, ctx);
|
|
888
|
+
}),
|
|
889
|
+
},
|
|
890
|
+
'ixo.entity.revoke-account-authz': {
|
|
891
|
+
summary: "remove a permission the owner's entity gave another account.",
|
|
892
|
+
rules:
|
|
893
|
+
'Removes one kind of permission (`permission`, as listed for `ixo.entity.grant-account-authz`) that an entity the owner owns (`entityId`) gave an account (`granteeAddress`). See what the entity gave with chain_lookup authz_grants (given, for the entity’s admin account; chain_lookup entity lists its accounts). It removes the whole permission, for every collection it covers: to remove one collection only, add an `ixo.entity.grant-account-authz` step after it that gives the rest back. Say it like: “Stop Thandi (ixo1qy…lvf0) from filing claims for Clean Water Project.”',
|
|
894
|
+
schema: z.strictObject({
|
|
895
|
+
type: z.literal('ixo.entity.revoke-account-authz'),
|
|
896
|
+
entityId: entityDid('The entity that gave the permission.'),
|
|
897
|
+
granteeAddress: address('Who loses it'),
|
|
898
|
+
permission: z
|
|
899
|
+
.literal([...CHAIN_TX_ENTITY_PERMISSIONS])
|
|
900
|
+
.describe('The permission to remove.'),
|
|
901
|
+
}),
|
|
902
|
+
},
|
|
903
|
+
'ixo.entity.grant-account-authz': {
|
|
904
|
+
summary:
|
|
905
|
+
"let an account (or the owner) act for the owner's entity in one way: file or check claims in a collection, change its collections, or give others claim permissions.",
|
|
906
|
+
rules: `Gives an account (\`granteeAddress\`; the owner’s own address is fine) one permission from an entity the owner owns (\`entityId\`). \`permission\`: \`file-claims\` or \`check-claims\` in one collection (then \`collectionId\` and \`maxClaims\`, 1 to ${CHAIN_TX_MAX_CLAIM_PERMISSION}, are required; optional \`reservationSeconds\` for file-claims and \`decideBefore\` for check-claims), or, for every collection of the entity, \`change-collection-state\`, \`change-collection-dates\`, \`change-collection-quota\`, \`change-collection-payments\` or \`authorize-claim-agents\` (lets them give others file and check permissions, with no limit); for these five leave \`collectionId\`, \`maxClaims\`, \`reservationSeconds\` and \`decideBefore\` out. \`expiresAt\` (optional) ends it; left out, an end date the account already has for this permission is kept, else it never ends. Giving file or check permission for one collection keeps what the account already has for others. Ask who and exactly which permission; never give more than the owner named, and say plainly that changing payments and letting others file or check claims can move money. Say it like: “Let Thandi (ixo1qy…lvf0) check up to 100 claims in the Clean Water collection until 31 December.”`,
|
|
907
|
+
schema: z
|
|
908
|
+
.strictObject({
|
|
909
|
+
type: z.literal('ixo.entity.grant-account-authz'),
|
|
910
|
+
entityId: entityDid('The entity that gives the permission.'),
|
|
911
|
+
granteeAddress: address('Who gets the permission'),
|
|
912
|
+
permission: z
|
|
913
|
+
.literal([...CHAIN_TX_ENTITY_PERMISSIONS])
|
|
914
|
+
.describe('The permission to give.'),
|
|
915
|
+
collectionId: uint64(
|
|
916
|
+
'file-claims and check-claims only: the collection (digits).',
|
|
917
|
+
).optional(),
|
|
918
|
+
maxClaims: claimCount(
|
|
919
|
+
`file-claims and check-claims only: how many claims, 1 to ${CHAIN_TX_MAX_CLAIM_PERMISSION}.`,
|
|
920
|
+
).optional(),
|
|
921
|
+
reservationSeconds: seconds(
|
|
922
|
+
'file-claims only: how long a reservation holds the payment, in seconds.',
|
|
923
|
+
).optional(),
|
|
924
|
+
decideBefore: instant(
|
|
925
|
+
'check-claims only: the date checks must be done by.',
|
|
926
|
+
).optional(),
|
|
927
|
+
expiresAt: instant('Optional: when the permission ends.').optional(),
|
|
928
|
+
})
|
|
929
|
+
.superRefine((message, ctx) => {
|
|
930
|
+
const claims =
|
|
931
|
+
message.permission === 'file-claims' ||
|
|
932
|
+
message.permission === 'check-claims';
|
|
933
|
+
for (const key of ['collectionId', 'maxClaims'] as const) {
|
|
934
|
+
if (claims && message[key] === undefined)
|
|
935
|
+
ctx.addIssue({
|
|
936
|
+
code: 'custom',
|
|
937
|
+
path: [key],
|
|
938
|
+
message: 'Required for file-claims and check-claims.',
|
|
939
|
+
});
|
|
940
|
+
if (!claims && message[key] !== undefined)
|
|
941
|
+
ctx.addIssue({
|
|
942
|
+
code: 'custom',
|
|
943
|
+
path: [key],
|
|
944
|
+
message: 'Only for file-claims and check-claims.',
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
roleOptions(message, message.permission, ctx);
|
|
948
|
+
}),
|
|
949
|
+
},
|
|
950
|
+
'ixo.entity.create': {
|
|
951
|
+
summary:
|
|
952
|
+
'create a new entity (a group, project, oracle and so on) that the owner owns.',
|
|
953
|
+
rules:
|
|
954
|
+
'Creates a new entity the owner owns, with its own identity and an account for funds; it can’t be deleted later. `entityType` is lowercase: `dao` (a group), `project`, `oracle`, or `protocol/<kind>` for a template. Its name comes from a Profile document that must ALREADY be stored: `profile` is that document’s https link (`serviceEndpoint`) and `proof` (content id). There is no tool here that stores one: without it the entity has no name until one is linked, so tell the owner. Optional: `protocolId` (the template it is based on, a did:ixo DID) and `startDate`/`endDate`. The runtime adds the owner as its owner. The result does not include the new entity’s id: once the owner has approved it and it went through (hub_action_status), find it with chain_lookup entities. Say it like: “Create a new project called Clean Water, owned by you. It can’t be deleted later.”',
|
|
955
|
+
schema: z
|
|
956
|
+
.strictObject({
|
|
957
|
+
type: z.literal('ixo.entity.create'),
|
|
958
|
+
entityType: z
|
|
959
|
+
.string()
|
|
960
|
+
.max(65)
|
|
961
|
+
.regex(
|
|
962
|
+
ENTITY_TYPE,
|
|
963
|
+
'Lowercase, such as dao, project, oracle or protocol/claim.',
|
|
964
|
+
)
|
|
965
|
+
.describe('The kind of entity.'),
|
|
966
|
+
profile: z
|
|
967
|
+
.strictObject({
|
|
968
|
+
serviceEndpoint: link(
|
|
969
|
+
false,
|
|
970
|
+
'The stored Profile document’s https link.',
|
|
971
|
+
),
|
|
972
|
+
proof,
|
|
973
|
+
})
|
|
974
|
+
.optional()
|
|
975
|
+
.describe('The already stored Profile document (its name).'),
|
|
976
|
+
protocolId: ixoDid('Optional: the template it is based on.').optional(),
|
|
977
|
+
startDate: instant('Optional: when it starts.').optional(),
|
|
978
|
+
endDate: instant('Optional: when it ends.').optional(),
|
|
979
|
+
})
|
|
980
|
+
.superRefine(endAfterStart),
|
|
981
|
+
},
|
|
982
|
+
'ixo.iid.add-service': {
|
|
983
|
+
summary:
|
|
984
|
+
"add a service link (where apps reach a service) to an entity the owner controls or to the owner's own identity.",
|
|
985
|
+
rules: `${IID_RULES} ${IID_ADD_RULE} \`service\`: \`id\`, \`type\` (the kind of service, for example \`linkedDomains\`) and \`serviceEndpoint\` (an https:// or ipfs:// link). Say it like: “Add the website link https://cleanwater.org to Clean Water Project.”`,
|
|
986
|
+
schema: z.strictObject({
|
|
987
|
+
type: z.literal('ixo.iid.add-service'),
|
|
988
|
+
did: ixoDid('The identity record changed.'),
|
|
989
|
+
service: z.strictObject({
|
|
990
|
+
id: itemId('The new link’s id.'),
|
|
991
|
+
type: iidType('The kind of service.'),
|
|
992
|
+
serviceEndpoint: link(true, 'Where the service is.'),
|
|
993
|
+
}),
|
|
994
|
+
}),
|
|
995
|
+
},
|
|
996
|
+
'ixo.iid.delete-service': {
|
|
997
|
+
summary: 'remove a service link from such an identity.',
|
|
998
|
+
rules: `${IID_RULES} ${IID_REMOVE_RULE} \`serviceId\`: the link’s id. Say it like: “Remove the website link from Clean Water Project.”`,
|
|
999
|
+
schema: z.strictObject({
|
|
1000
|
+
type: z.literal('ixo.iid.delete-service'),
|
|
1001
|
+
did: ixoDid('The identity record changed.'),
|
|
1002
|
+
serviceId: removeId(
|
|
1003
|
+
'The id of the link to remove, as the record shows it.',
|
|
1004
|
+
),
|
|
1005
|
+
}),
|
|
1006
|
+
},
|
|
1007
|
+
'ixo.iid.add-linked-resource': {
|
|
1008
|
+
summary: 'link a document (a profile, page or file) to such an identity.',
|
|
1009
|
+
rules: `${IID_RULES} ${IID_ADD_RULE} The document must already be stored. \`resource\`: \`id\`, \`type\` (for example \`Settings\`), \`description\` (plain words, up to 300; written on chain exactly as given, so no links (web or email addresses), which belong in \`serviceEndpoint\`; a file name is fine), \`mediaType\` (for example \`application/json\`), \`serviceEndpoint\` (where it is stored), \`proof\` (its content id, or "" when it has none), \`encrypted\` (true or false) and optional \`right\`. Say it like: “Link the annual report to Clean Water Project.”`,
|
|
1010
|
+
schema: z.strictObject({
|
|
1011
|
+
type: z.literal('ixo.iid.add-linked-resource'),
|
|
1012
|
+
did: ixoDid('The identity record changed.'),
|
|
1013
|
+
resource: z.strictObject({
|
|
1014
|
+
id: itemId('The new document link’s id.'),
|
|
1015
|
+
type: iidType('The kind of document.'),
|
|
1016
|
+
description: linkDescription('What the document is, in plain words.'),
|
|
1017
|
+
mediaType: z
|
|
1018
|
+
.string()
|
|
1019
|
+
.max(MEDIA_TYPE_MAX)
|
|
1020
|
+
.regex(MEDIA_TYPE, 'A media type such as application/json.')
|
|
1021
|
+
.describe('Its format.'),
|
|
1022
|
+
serviceEndpoint: link(true, 'Where it is stored.'),
|
|
1023
|
+
proof: linkProof,
|
|
1024
|
+
encrypted: z.boolean().describe('true when it is encrypted.'),
|
|
1025
|
+
right: linkRight,
|
|
1026
|
+
}),
|
|
1027
|
+
}),
|
|
1028
|
+
},
|
|
1029
|
+
'ixo.iid.delete-linked-resource': {
|
|
1030
|
+
summary: 'remove a linked document from such an identity.',
|
|
1031
|
+
rules: `${IID_RULES} ${IID_REMOVE_RULE} \`resourceId\`: the document link’s id. Say it like: “Remove the annual report from Clean Water Project.”`,
|
|
1032
|
+
schema: z.strictObject({
|
|
1033
|
+
type: z.literal('ixo.iid.delete-linked-resource'),
|
|
1034
|
+
did: ixoDid('The identity record changed.'),
|
|
1035
|
+
resourceId: removeId(
|
|
1036
|
+
'The id of the document link to remove, as the record shows it.',
|
|
1037
|
+
),
|
|
1038
|
+
}),
|
|
1039
|
+
},
|
|
1040
|
+
'ixo.iid.add-linked-claim': {
|
|
1041
|
+
summary: 'link a claim (a credential or record) to such an identity.',
|
|
1042
|
+
rules: `${IID_RULES} \`claim\`: \`id\` (the claim’s id), \`type\`, \`description\` (plain words, up to 300; written on chain exactly as given, so no links (web or email addresses), which belong in \`serviceEndpoint\`; a file name is fine), \`serviceEndpoint\` (where it is stored), \`proof\` (its content id, or "" when it has none), \`encrypted\` (true or false) and optional \`right\`. Say it like: “Link the water-quality certificate to Clean Water Project.”`,
|
|
1043
|
+
schema: z.strictObject({
|
|
1044
|
+
type: z.literal('ixo.iid.add-linked-claim'),
|
|
1045
|
+
did: ixoDid('The identity record changed.'),
|
|
1046
|
+
claim: z.strictObject({
|
|
1047
|
+
id: claimId,
|
|
1048
|
+
type: iidType('The kind of claim.'),
|
|
1049
|
+
description: linkDescription('What the claim is, in plain words.'),
|
|
1050
|
+
serviceEndpoint: link(true, 'Where it is stored.'),
|
|
1051
|
+
proof: linkProof,
|
|
1052
|
+
encrypted: z.boolean().describe('true when it is encrypted.'),
|
|
1053
|
+
right: linkRight,
|
|
1054
|
+
}),
|
|
1055
|
+
}),
|
|
1056
|
+
},
|
|
1057
|
+
'ixo.iid.delete-linked-claim': {
|
|
1058
|
+
summary: 'remove a linked claim from such an identity.',
|
|
1059
|
+
rules: `${IID_RULES} ${IID_REMOVE_RULE} \`claimId\`: the linked claim’s id. Say it like: “Remove the water-quality certificate from Clean Water Project.”`,
|
|
1060
|
+
schema: z.strictObject({
|
|
1061
|
+
type: z.literal('ixo.iid.delete-linked-claim'),
|
|
1062
|
+
did: ixoDid('The identity record changed.'),
|
|
1063
|
+
claimId: removeId(
|
|
1064
|
+
'The id of the linked claim to remove, as the record shows it.',
|
|
1065
|
+
),
|
|
1066
|
+
}),
|
|
1067
|
+
},
|
|
1068
|
+
'ixo.iid.add-linked-entity': {
|
|
1069
|
+
summary:
|
|
1070
|
+
'record that another entity is linked to this one, with a relationship such as member or admin.',
|
|
1071
|
+
rules: `${IID_RULES} \`entity\`: \`id\` (the other entity’s did:ixo DID, or an ixo1… account or group address, which Portal writes as \`{id}#ixo1…\`), \`type\` (its kind, for example \`dao\`), \`relationship\` (for example \`member\`, \`admin\` or \`Governs\`) and optional \`service\` (a service name of it, "" for none). Apps may use the link to give the other entity a role. Say it like: “Record River Group as a member of Clean Water Project.”`,
|
|
1072
|
+
schema: z.strictObject({
|
|
1073
|
+
type: z.literal('ixo.iid.add-linked-entity'),
|
|
1074
|
+
did: ixoDid('The identity record changed.'),
|
|
1075
|
+
entity: z.strictObject({
|
|
1076
|
+
id: z
|
|
1077
|
+
.string()
|
|
1078
|
+
.max(DID_MAX)
|
|
1079
|
+
.refine(
|
|
1080
|
+
(value) =>
|
|
1081
|
+
IXO_DID.test(value) ||
|
|
1082
|
+
(value.length <= 67 && LINKED_ENTITY_ADDRESS.test(value)),
|
|
1083
|
+
{
|
|
1084
|
+
message:
|
|
1085
|
+
'A did:ixo:… DID, an ixo1… account or contract address, or {id}#ixo1….',
|
|
1086
|
+
},
|
|
1087
|
+
)
|
|
1088
|
+
.describe('The other entity: its DID or address.'),
|
|
1089
|
+
type: iidType('Its kind.'),
|
|
1090
|
+
relationship: z
|
|
1091
|
+
.string()
|
|
1092
|
+
.max(64)
|
|
1093
|
+
.regex(
|
|
1094
|
+
RELATIONSHIP,
|
|
1095
|
+
'Letters, digits, _ or -, starting with a letter (up to 64).',
|
|
1096
|
+
)
|
|
1097
|
+
.describe('How it is linked, such as member or Governs.'),
|
|
1098
|
+
service: z
|
|
1099
|
+
.string()
|
|
1100
|
+
.max(ITEM_ID_MAX)
|
|
1101
|
+
.regex(
|
|
1102
|
+
SERVICE_REF,
|
|
1103
|
+
'A service name such as svc1, {id}#name, or "" for none.',
|
|
1104
|
+
)
|
|
1105
|
+
.optional()
|
|
1106
|
+
.describe('Optional: a service name of the other entity.'),
|
|
1107
|
+
}),
|
|
1108
|
+
}),
|
|
1109
|
+
},
|
|
1110
|
+
'ixo.iid.delete-linked-entity': {
|
|
1111
|
+
summary: 'remove the link to another entity from such an identity.',
|
|
1112
|
+
rules: `${IID_RULES} ${IID_REMOVE_RULE} \`entityId\`: the linked entity’s id. Say it like: “Remove River Group as a member of Clean Water Project.”`,
|
|
1113
|
+
schema: z.strictObject({
|
|
1114
|
+
type: z.literal('ixo.iid.delete-linked-entity'),
|
|
1115
|
+
did: ixoDid('The identity record changed.'),
|
|
1116
|
+
entityId: removeId(
|
|
1117
|
+
'The id of the linked entity to remove, as the record shows it.',
|
|
1118
|
+
),
|
|
1119
|
+
}),
|
|
1120
|
+
},
|
|
1121
|
+
'ixo.iid.add-context': {
|
|
1122
|
+
summary:
|
|
1123
|
+
'add a context reference (how apps read the details) to such an identity.',
|
|
1124
|
+
rules: `${IID_RULES} \`context\`: \`key\` (letters, digits, _ or -, starting with a letter) and \`val\` (an https:// link or a did:ixo DID it points to). Say it like: “Tell apps to read Clean Water Project’s details with the IXO vocabulary.”`,
|
|
1125
|
+
schema: z.strictObject({
|
|
1126
|
+
type: z.literal('ixo.iid.add-context'),
|
|
1127
|
+
did: ixoDid('The identity record changed.'),
|
|
1128
|
+
context: z.strictObject({
|
|
1129
|
+
key: z
|
|
1130
|
+
.string()
|
|
1131
|
+
.max(64)
|
|
1132
|
+
.regex(
|
|
1133
|
+
CONTEXT_KEY,
|
|
1134
|
+
'Letters, digits, _ or -, starting with a letter (up to 64).',
|
|
1135
|
+
)
|
|
1136
|
+
.describe('The name of the reference.'),
|
|
1137
|
+
val: z
|
|
1138
|
+
.string()
|
|
1139
|
+
.max(URL_MAX)
|
|
1140
|
+
.refine(
|
|
1141
|
+
(value) =>
|
|
1142
|
+
isLink(value, false) ||
|
|
1143
|
+
(value.length <= URL_MAX && IXO_DID.test(value)),
|
|
1144
|
+
{ message: 'An https:// link or a did:ixo DID, at most 300.' },
|
|
1145
|
+
)
|
|
1146
|
+
.describe('What it points to.'),
|
|
1147
|
+
}),
|
|
1148
|
+
}),
|
|
1149
|
+
},
|
|
1150
|
+
'ixo.iid.delete-context': {
|
|
1151
|
+
summary: 'remove a context reference from such an identity.',
|
|
1152
|
+
rules: `${IID_RULES} ${IID_REMOVE_RULE} \`key\`: the reference’s name. Say it like: “Remove the IXO vocabulary reference from Clean Water Project.”`,
|
|
1153
|
+
schema: z.strictObject({
|
|
1154
|
+
type: z.literal('ixo.iid.delete-context'),
|
|
1155
|
+
did: ixoDid('The identity record changed.'),
|
|
1156
|
+
key: removeId(
|
|
1157
|
+
'The name of the reference to remove, as the record shows it.',
|
|
1158
|
+
),
|
|
1159
|
+
}),
|
|
1160
|
+
},
|
|
1161
|
+
} as const satisfies Record<ChainTxMessageType, ChainTxCatalogEntry>;
|
|
1162
|
+
|
|
1163
|
+
/** What every message type shares, returned once by describe_chain_messages. */
|
|
1164
|
+
export const CHAIN_TX_GENERAL_RULES = [
|
|
1165
|
+
'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused), validators `ixovaloper1…`, entities `did:ixo:entity:…`. Copy every id, address and number exactly from a lookup (chain_lookup, hub_action_options) or from the owner; never invent or guess one. Ids (`claimId`, `collectionId`, token `id`, proofs) use only letters, digits and `: . _ / -`.',
|
|
1166
|
+
'Give only the fields listed for a type: the runtime adds the chain, the network, the signer and the owner’s own DID and address where a message needs them, and Auth Hub sets the fee. Amounts: coin sends and stakes are what the owner says in the token’s units ("100" IXO); impact tokens are whole tokens; collection payments are base units (1 IXO = 1000000 uixo). Dates are RFC 3339 with Z or an offset, from the owner’s time zone.',
|
|
1167
|
+
`Up to ${CHAIN_TX_MAX_MESSAGES} messages per transaction, run in the order given (put them in the order the owner said them, and keep one decision in one card); all succeed or none do. A step that needs something stored first (a dispute statement, a profile, a document) can only be prepared once it is stored.`,
|
|
1168
|
+
'How to talk about it: before preparing, say in one or two plain, everyday sentences what will happen and what it means for the owner (what leaves their wallet, what someone else will be able to do, how long it takes, whether it can be undone), the way each type’s “Say it like” does. Use names (of the validator, collection, entity or person) when a lookup gives them. Never say message type names or chain words such as authz, grant, MsgExec, bech32, valoper, uixo, base units, DID, IID, delegate or redelegate: say stake, unstake, move stake, permission, account, identity and IXO. Show an address only when the owner needs it to check who receives something. The review card in Auth Hub shows every exact value.',
|
|
1169
|
+
].join(' ');
|
|
1170
|
+
|
|
1171
|
+
/** A catalogue message as its schema parses it. */
|
|
1172
|
+
export type ChainTxCatalogMessage = z.output<
|
|
1173
|
+
(typeof CHAIN_TX_CATALOG)[ChainTxMessageType]['schema']
|
|
1174
|
+
>;
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* A parsed catalogue message as the host takes it. The two permission
|
|
1178
|
+
* types are one flat object for the model (`role` / `permission` and every
|
|
1179
|
+
* option); their schemas refuse an option the role or permission does not
|
|
1180
|
+
* take, and this builds the protocol's role- and permission-specific shape
|
|
1181
|
+
* from what is left.
|
|
1182
|
+
*/
|
|
1183
|
+
export function chainTxMessageInput(
|
|
1184
|
+
message: ChainTxCatalogMessage,
|
|
1185
|
+
): ChainTxMessageInput {
|
|
1186
|
+
switch (message.type) {
|
|
1187
|
+
case 'ixo.claims.create-claim-authorization': {
|
|
1188
|
+
const { role, reservationSeconds, decideBefore, ...rest } = message;
|
|
1189
|
+
return role === 'file-claims'
|
|
1190
|
+
? {
|
|
1191
|
+
...rest,
|
|
1192
|
+
role,
|
|
1193
|
+
...(reservationSeconds === undefined ? {} : { reservationSeconds }),
|
|
1194
|
+
}
|
|
1195
|
+
: {
|
|
1196
|
+
...rest,
|
|
1197
|
+
role,
|
|
1198
|
+
...(decideBefore === undefined ? {} : { decideBefore }),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
case 'ixo.entity.grant-account-authz': {
|
|
1202
|
+
const {
|
|
1203
|
+
permission,
|
|
1204
|
+
collectionId: collection,
|
|
1205
|
+
maxClaims,
|
|
1206
|
+
reservationSeconds,
|
|
1207
|
+
decideBefore,
|
|
1208
|
+
...rest
|
|
1209
|
+
} = message;
|
|
1210
|
+
if (permission === 'file-claims' || permission === 'check-claims') {
|
|
1211
|
+
// The schema requires both for a claim permission.
|
|
1212
|
+
if (collection === undefined || maxClaims === undefined)
|
|
1213
|
+
throw new Error(
|
|
1214
|
+
'A claim permission without collectionId or maxClaims passed the catalogue schema',
|
|
1215
|
+
);
|
|
1216
|
+
return permission === 'file-claims'
|
|
1217
|
+
? {
|
|
1218
|
+
...rest,
|
|
1219
|
+
permission,
|
|
1220
|
+
collectionId: collection,
|
|
1221
|
+
maxClaims,
|
|
1222
|
+
...(reservationSeconds === undefined
|
|
1223
|
+
? {}
|
|
1224
|
+
: { reservationSeconds }),
|
|
1225
|
+
}
|
|
1226
|
+
: {
|
|
1227
|
+
...rest,
|
|
1228
|
+
permission,
|
|
1229
|
+
collectionId: collection,
|
|
1230
|
+
maxClaims,
|
|
1231
|
+
...(decideBefore === undefined ? {} : { decideBefore }),
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
return { ...rest, permission };
|
|
1235
|
+
}
|
|
1236
|
+
default:
|
|
1237
|
+
return message;
|
|
1238
|
+
}
|
|
1239
|
+
}
|