@palliora.org/chainsdk 0.3.3 → 0.5.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/CHAIN-RULES.md ADDED
@@ -0,0 +1,760 @@
1
+ # Palliora chain rules
2
+
3
+ The behavioural rules of the Palliora chain, for anyone — human or agent — building an
4
+ application on `@palliora.org/chainsdk`.
5
+
6
+ The SDK README documents *which function to call*. This document covers *what the chain
7
+ does with the call*: what it charges, what it reserves, who has to agree, and why a
8
+ correctly-shaped transaction still gets rejected. These rules are not visible from the
9
+ SDK's TypeScript signatures, which is why they are written down separately.
10
+
11
+ Source citations point into [`palliora-org/palliora`](https://github.com/palliora-org/palliora)
12
+ (the chain) and this SDK. Line numbers drift; the function and type names are the stable
13
+ anchors. When this document and the pallet disagree, the pallet is right.
14
+
15
+ ---
16
+
17
+ ## 1. Orientation: which repository owns which fact
18
+
19
+ | Repository | Owns | You need it when |
20
+ |---|---|---|
21
+ | [`palliora`](https://github.com/palliora-org/palliora) | The chain. `pallets/compute` (agreements, fees, settlement), `pallets/dactr` (DA + guardian groups), `runtime` (constants), `guardian` (threshold crypto service) | Deciding what the chain will accept or charge |
22
+ | [`palliora-sdk`](https://github.com/palliora-org/chainsdk) | TypeScript wrappers, type registrations (`src/chain/spec.ts`), crypto helpers | Writing application code |
23
+ | `compute-core` | `orchestrator` (executes jobs), `result_relay` (submits `compute.result`), `docker_gateway` | Running a compute node, or understanding how a result reaches the chain |
24
+ | [`palliora-core`](https://github.com/palliora-org/palliora-core) | Kate commitments, DA primitives shared by the chain | Working on DA internals — rarely needed by applications |
25
+
26
+ **An application developer only ever signs transactions from `palliora-sdk`.** The
27
+ orchestrator and result relay are node-operator infrastructure: you do not call them,
28
+ but the fees you offer pay them, and the results you wait for come from them.
29
+
30
+ ---
31
+
32
+ ## 2. The money model
33
+
34
+ This is the single most common source of failure, so it comes first.
35
+
36
+ ### 2.1 Two gates, not one
37
+
38
+ An `Active` or `Subscription` agreement must pass **two independent checks**. They test
39
+ different fields, they are enforced in different places, and passing one tells you nothing
40
+ about the other.
41
+
42
+ | | Gate 1 — the chain's fee floor | Gate 2 — the guardians' rate threshold |
43
+ |---|---|---|
44
+ | **Tests** | `compute.fees` (the absolute amount) | `compute.computeRate` (the per-ms rate) |
45
+ | **Against** | A formula over live chain parameters | Each guardian's own declared minimum |
46
+ | **Enforced by** | `CheckCompute` signed extension, at validation | Guardians' offchain workers, before inclusion |
47
+ | **Enforced in** | [`pallets/compute/src/lib.rs`](https://github.com/palliora-org/palliora/blob/main/pallets/compute/src/lib.rs) — `ensure_min_free_balance` | [`pallets/compute/src/offchain.rs`](https://github.com/palliora-org/palliora/blob/main/pallets/compute/src/offchain.rs) — `decide` |
48
+ | **Failure looks like** | Transaction rejected: `InsufficientFreeBalance` | Transaction included, then `AgreementFailed` event |
49
+ | **Failure timing** | Immediate, at submission | After all guardians respond — or never, if one is offline |
50
+
51
+ **How Gate 2 actually plays out.** `CheckCompute` gives a `compute.agreement` transaction a
52
+ `requires` tag *per named guardian*, satisfied only when that guardian submits its
53
+ `agreement_response`. Until all of them have, your transaction sits in the node's **future
54
+ queue** — not rejected, not included, no error. Guardians read that same future queue to
55
+ decide (`accept_agreements` in `offchain.rs`), which is what lets them judge an agreement
56
+ before it is ever in a block.
57
+
58
+ The consequences are worth stating explicitly, because the failure is silent:
59
+
60
+ - If a named guardian is offline or never responds, the agreement **never gets included and
61
+ never errors**. A hanging `signAndSend` is the expected symptom.
62
+ - If every guardian responds and any one rejected, the transaction *is* included, and
63
+ `compute.agreement` emits `AgreementFailed` and returns without reserving anything.
64
+ - So name guardians you have reason to believe are live — from `getGuardianList()` or the
65
+ current era's `guardian.guardians` — and treat a stalled agreement as a guardian
66
+ availability problem, not a fee problem.
67
+
68
+ A generous `fees` does not buy you past Gate 2, and a generous `computeRate` does not by
69
+ itself satisfy Gate 1 — though it does *raise* Gate 1, because `computeRate` is one of the
70
+ floor's four components. Raising the rate to satisfy guardians raises the minimum `fees`
71
+ you must also offer.
72
+
73
+ `Dormant` contracts are exempt from both gates. They reserve nothing, so they may offer
74
+ any `fees`, including zero.
75
+
76
+ ### 2.2 Gate 1: the fee floor
77
+
78
+ `fees` must be at least the sum of four components, and the signer must actually hold
79
+ that much free balance:
80
+
81
+ ```
82
+ result_fee = MaxDaStorageSize × ProviderStorageRate
83
+ input_fee = usage_price of the input contract (0 if no ContractId input)
84
+ threshold_decryption_fee = ThresholdDecryptionFee (flat)
85
+ offered_component = computeRate × MillisecondsPerBlock (one block of compute)
86
+
87
+ min_fees = result_fee + input_fee + threshold_decryption_fee + offered_component
88
+ ```
89
+
90
+ Defined once in `pallet_compute::Pallet::fee_components` and used from three places —
91
+ `ensure_min_free_balance` at validation, the guardian offchain worker, and `compute.result`
92
+ at settlement — specifically so the three cannot drift apart.
93
+
94
+ **Do not hard-code these numbers.** The first three parameters are root-settable at
95
+ runtime (`compute.setMaxDaStorageSize`, `setProviderStorageRate`,
96
+ `setThresholdDecryptionFee`). A stale copy fails silently, as an
97
+ `InsufficientFreeBalance` you cannot explain. Query them:
98
+
99
+ ```ts
100
+ import { estimateMinFee, getFeeParams, fromAtomicPaliAmount } from "@palliora.org/chainsdk";
101
+
102
+ const floor = await estimateMinFee({ computeRate: "0.000000001" });
103
+ console.log(fromAtomicPaliAmount(floor.minFee)); // smallest acceptable `fees`
104
+ console.log(floor.resultFee, floor.offeredComponent); // the breakdown, in atomic units
105
+
106
+ const params = await getFeeParams(); // the four raw chain values
107
+ ```
108
+
109
+ `estimateMinFee` mirrors `fee_components` exactly. Offer at least `minFee`; offer more to
110
+ buy more compute time.
111
+
112
+ Worked example, at the genesis parameters (`MaxDaStorageSize` 10485760,
113
+ `ProviderStorageRate` 1e9, `ThresholdDecryptionFee` 1e15, block time 500ms, 18 decimals):
114
+
115
+ ```
116
+ result_fee = 10485760 × 1e9 = 1.048576e16 ≈ 0.0105 PALI
117
+ threshold_decryption_fee = 1e15 = 0.001 PALI
118
+ offered_component = computeRate × 500
119
+ min_fees (no input contract, rate 0) ≈ 0.0115 PALI
120
+ ```
121
+
122
+ Verify against the live chain rather than trusting these figures — they are genesis
123
+ values for one network, not constants.
124
+
125
+ ### 2.3 How much compute your fee buys
126
+
127
+ Beyond the floor, `fees` is a *budget*. Compute is billed at `computeRate` per millisecond
128
+ of actual execution, settled when the result lands:
129
+
130
+ ```
131
+ compute_fee = computeRate × compute_duration_ms
132
+ budget_for_compute = fees − result_fee − input_fee − threshold_decryption_fee
133
+ max_duration_ms = budget_for_compute ÷ computeRate
134
+ ```
135
+
136
+ Anything unspent is returned to you when the contract settles.
137
+
138
+ ### 2.4 Gate 2: guardian rate thresholds
139
+
140
+ Every guardian declares a minimum rate it will take work at, **per compute type**:
141
+
142
+ ```rust
143
+ pub enum ComputeType { Trusted, Tee, Mpc, Fhe, Zkp }
144
+ pub type FeeThresholds = BoundedVec<(ComputeType, u128), ConstU32<8>>;
145
+ ```
146
+
147
+ A compute type absent from a guardian's list carries **no threshold and reads as zero** —
148
+ that guardian accepts any rate for it. The compute type is not something you set directly;
149
+ it is derived from your contract's `confidentiality` field:
150
+
151
+ | `confidentiality` in your contract | `ComputeType` the threshold is looked up under |
152
+ |---|---|
153
+ | `{ Trusted: <index> }` | `Trusted` |
154
+ | `"TEE"` | `Tee` |
155
+ | `"SMPC"` | `Mpc` |
156
+ | `"FHE"` | `Fhe` |
157
+
158
+ Each guardian named in `contract.guardians` independently decides. If **any** of them
159
+ rejects, the agreement fails — `compute.agreement` emits `AgreementFailed` and returns
160
+ without reserving anything. Choosing a confidentiality level therefore changes the price
161
+ floor you must clear, because it changes which threshold applies.
162
+
163
+ A guardian may instead be pointed at an **oracle quote**: pass an `oracle_quote_id` to
164
+ `compute.agreement`, and guardians price against the oracle's quoted rate rather than
165
+ their own threshold. If the oracle is unreachable, times out (500ms), or does not know the
166
+ quote ID, each guardian silently falls back to its own threshold — so an oracle quote is a
167
+ hint, never a guarantee.
168
+
169
+ ### 2.5 Who gets paid, and when
170
+
171
+ At `compute.result`, the reserved deposit is split:
172
+
173
+ | Recipient | Amount |
174
+ |---|---|
175
+ | Owner of the input contract | `input_fee` (only when input is a `ContractId`) |
176
+ | The contract's guardians | `threshold_decryption_fee`, split equally — **only when there is more than one guardian** |
177
+ | The result submitter | `result_fee + (computeRate × compute_duration_ms)` |
178
+ | You, the contract owner | Everything still reserved, refunded at settlement |
179
+
180
+ The guardian split divides by `max(guardian_count, 3)`, so a 2-guardian contract leaves a
181
+ third share reserved, which returns to you at settlement rather than being paid out.
182
+
183
+ ---
184
+
185
+ ## 3. The `Contract` object
186
+
187
+ `compute.agreement` takes exactly one structured argument, and you build it by hand. The
188
+ SDK types it loosely (`compute: Record<string, unknown>`, `preCheck?: unknown`,
189
+ `resultCipher: unknown`), so TypeScript will not catch a wrong shape — the chain will,
190
+ usually as an opaque decode failure. This section is the field reference.
191
+
192
+ Two conventions apply throughout, and both bite:
193
+
194
+ - **Field names are camelCase**, not the Rust snake_case. `computeRate`, not
195
+ `compute_rate`; `resultCipher`, not `result_cipher`. The names come from the manual type
196
+ registry in `src/chain/spec.ts`.
197
+ - **Omitted fields do not error.** `Option` fields default to `None`, and missing struct
198
+ members default to zero. Passing `{ Url: { url } }` without `size` silently sends
199
+ `size: 0`. Be explicit about anything that matters.
200
+
201
+ ### 3.1 Shape at a glance
202
+
203
+ ```ts
204
+ {
205
+ contractType: "Dormant" | "Active" | "Subscription",
206
+ guardians: string[], // account IDs; indices referenced below
207
+ preCheck: null, // Option<ComputeInfo> — input verification
208
+ compute: { // ComputeInfo — the primary step
209
+ cipher: "Plaintext", // how `input` is encrypted
210
+ computerIndices: [0, 1, 2], // which of `guardians` execute this step
211
+ fees: "10000000000000000", // atomic units; see §2.2 for the floor
212
+ computeRate: "1000000000", // atomic units, per millisecond
213
+ deadline: 0, // block number; 0 = none
214
+ confidentiality: { Trusted: 0 },// selects the ComputeType priced in §2.4
215
+ feeFunction: null, // Option<u8>
216
+ programEnv: null, // Option<Vec<u8>> — env vars for the program
217
+ input: { Inline: { data: [] } },// DAInput — the data
218
+ program: { NativeExecute: "Inference" }, // DAInput — the code
219
+ metadata: null, // Option<ComputeMetadata>
220
+ },
221
+ postCheck: null, // Option<ComputeInfo> — result verification
222
+ resultCipher: "Plaintext", // how the result is encrypted back to you
223
+ currencyId: "Native",
224
+ }
225
+ ```
226
+
227
+ `buildFee({ amount, computeRate })` fills in `fees` and `computeRate` from human PALI
228
+ strings — prefer it over writing atomic units by hand.
229
+
230
+ ### 3.2 `Contract` fields
231
+
232
+ | Field | Type | Notes |
233
+ |---|---|---|
234
+ | `contractType` | enum | Governs reservation, settlement and invocability. See §3.6 |
235
+ | `guardians` | `Vec<AccountId>` | The participant set. Every index elsewhere in the contract points into **this list, in this order** |
236
+ | `preCheck` | `Option<ComputeInfo>` | Input verification step. `null` to skip |
237
+ | `compute` | `ComputeInfo` | The primary step. Required — this is what gets billed |
238
+ | `postCheck` | `Option<ComputeInfo>` | Result verification step. `null` to skip |
239
+ | `resultCipher` | `CipherSuite` | Encryption applied to the result on its way back to you |
240
+ | `currencyId` | enum | `"Native"` \| `"USDC"` \| `{ ForeignAsset: n }`. The deposit is reserved in, and settlement paid from, this currency. `createAgreement` defaults it to `"Native"` |
241
+
242
+ `guardians` is validated by `CheckCompute` before the contract is ever decoded by the
243
+ pallet, with three separate rules — each a distinct rejection code:
244
+
245
+ | Rule | Rejected as |
246
+ |---|---|
247
+ | Must be non-empty, unless `compute.program` is `"Null"` | `Custom(149)` |
248
+ | Entries must be unique — no duplicates | `Custom(145)` |
249
+ | Every entry must be a registered, staked guardian | `Custom(148)` |
250
+
251
+ ### 3.3 `ComputeInfo` fields
252
+
253
+ The same struct is used for `preCheck`, `compute` and `postCheck`. **Only `compute` is
254
+ billed** — fee fields on the check steps are not what settlement reads.
255
+
256
+ | Field | Type | Notes |
257
+ |---|---|---|
258
+ | `cipher` | `CipherSuite` | How `input` is encrypted. `"Plaintext"` when it is not |
259
+ | `computerIndices` | `Vec<u32>` | Indices into `Contract.guardians` that execute this step. Usually all of them: `guardians.map((_, i) => i)` |
260
+ | `fees` | `u128` | Total offered, atomic units. Must clear the floor (§2.2) for `Active`/`Subscription` |
261
+ | `computeRate` | `u128` | Atomic units **per millisecond**. Must be non-zero except on `Dormant`. Weighed against guardian thresholds (§2.4) |
262
+ | `deadline` | `u64` | Block number by which the step must complete. `0` means no deadline — and is not the same as the contract-level deadline (§3.6) |
263
+ | `confidentiality` | enum | `{ Trusted: <index into guardians> }`, or `"TEE"` / `"FHE"` / `"SMPC"`. Determines which guardian threshold prices the offer |
264
+ | `feeFunction` | `Option<u8>` | Dynamic fee function selector. `null` in every current SDK helper |
265
+ | `programEnv` | `Option<Vec<u8>>` | Environment for program execution. Omitted by all SDK helpers — set it explicitly if you need it |
266
+ | `input` | `DAInput` | Where the data comes from |
267
+ | `program` | `DAInput` | Where the code comes from. Same type as `input` |
268
+ | `metadata` | `Option<ComputeMetadata>` | `{ name, description, storeType, groupId }`. `groupId` links the entry to a guardian group (§4.3). Used when registering datasets/models via `Dormant` contracts |
269
+
270
+ ### 3.4 `DAInput` — how `input` and `program` are located
271
+
272
+ One enum serves both fields, which is why `program` can be a URL, a container image
273
+ reference, or a built-in. Variants:
274
+
275
+ | Variant | Shape | Use |
276
+ |---|---|---|
277
+ | `"Null"` | — | Nothing. On `program`, this is the one case where `guardians` may be empty |
278
+ | `Inline` | `{ data: number[] }` | Bytes carried in the extrinsic. Simplest, but counts against block size |
279
+ | `ChainTransaction` | `{ blockNumber, extrinsicIndex }` | Points at data already submitted on-chain |
280
+ | `ContractId` | `{ id: [u8; 32] }` | References another contract. **This is what triggers `input_fee`** — the referenced contract's owner is paid its `usage_price` (§2.5) |
281
+ | `Ipfs` | `{ cid: number[], size: u64 }` | Content-addressed pointer |
282
+ | `Url` | `{ url: number[], size: u64, hash: Option<number[]> }` | Remote fetch. `url` is UTF-8 bytes, not a string |
283
+ | `NativeExecute` | `"Inference"` \| `"ContractAccess"` | Built-in programs. `"Inference"` routes to the orchestrator's Ollama path |
284
+ | `NativeData` | `"DaFalse"` \| `"DaTrue"` | Built-in static data flags |
285
+
286
+ `Subscription` contracts are intended to take a `ContractId` input on each invocation —
287
+ that is how a subscription references the dataset it runs against.
288
+
289
+ ### 3.5 `CipherSuite` and confidentiality — two different things
290
+
291
+ These are easy to conflate, and they are unrelated:
292
+
293
+ - **`cipher` / `resultCipher`** are about *encryption of bytes*: `"Plaintext"`,
294
+ `{ ThresholdHybrid: {...} }` (needs a guardian group, §4.3), or
295
+ `{ AsymmetricHybrid: {...} }`. Use the `encryptedInference*` helpers rather than
296
+ assembling these by hand — the parameter structs are large and order-sensitive.
297
+ - **`confidentiality`** is about *where execution happens*: `Trusted`, `TEE`, `FHE`,
298
+ `SMPC`. It carries no key material. It selects which guardian fee threshold applies
299
+ (§2.4), so it is also a pricing decision.
300
+
301
+ A `"Plaintext"` contract with `{ Trusted: 0 }` is the normal starting point: unencrypted
302
+ data, executed by the guardian at index 0.
303
+
304
+ ### 3.6 Contract types and lifecycle
305
+
306
+ `ContractType` decides nearly everything about how a contract behaves.
307
+
308
+ | | `Dormant` | `Active` | `Subscription` |
309
+ |---|---|---|---|
310
+ | Reserves a deposit | No | Yes, `fees` | Yes, `fees` (upfront budget) |
311
+ | `computeRate` required non-zero | No | **Yes** (`ZeroComputeRate`) | **Yes** |
312
+ | Subject to the fee floor | No | Yes | Yes |
313
+ | Subject to guardian thresholds | No | Yes | Yes |
314
+ | `compute.invoke` allowed | No | No | **Yes** |
315
+ | Settles on | Never (registration only) | First result | Budget exhausted, or deadline |
316
+ | `usage_price` set to | `fees` | 0 | 0 |
317
+
318
+ **`Dormant`** registers something — a dataset, a model, a program, a set of terms — without
319
+ buying execution. Its `fees` becomes its `usage_price`: the amount another contract pays
320
+ its owner when referencing it via `DAInput::ContractId`. This is how you charge for data
321
+ you publish.
322
+
323
+ **`Active`** is one job. It reserves, runs, settles on the first result.
324
+
325
+ **`Subscription`** is a long-lived funded contract. `compute.agreement` reserves the whole
326
+ budget; each `compute.invoke` runs one job against it and stamps `invocation_block`.
327
+ `compute.result` bills that invocation but leaves the contract open. It settles when the
328
+ remaining reserve drops below one block of compute (`BudgetExhausted`) or the deadline
329
+ passes (`DeadlineReached`) — and, importantly, `invoke` **settles and returns `Ok`** in
330
+ both cases rather than erroring. A successful `invoke` is not proof that a job started;
331
+ check for a `ComputeInvoked` event, and treat `ContractSettled` as the terminal signal.
332
+
333
+ Every contract also gets a deadline at creation: `current_block + ContractDeadlineDuration`
334
+ (14400 blocks at genesis), independent of the `deadline` field inside `ComputeInfo`.
335
+
336
+ ### 3.7 Contract IDs are derived, not returned
337
+
338
+ ```
339
+ contract_id = blake2_256(signer_account_id ++ signer_nonce)
340
+ ```
341
+
342
+ using the nonce the `agreement` call is applied with. `createAgreement` reads the ID off
343
+ the `AgreementCreated` event, which is the reliable way to get it. Note the consequence:
344
+ the ID depends on the nonce, so a resubmitted or reordered transaction produces a
345
+ different contract.
346
+
347
+ Read a contract back with `api.query.compute.contracts(contractId)` — status, owner,
348
+ `origin_block`, `invocation_block`, `usage_price`, `contract_type`.
349
+
350
+ **`origin_block` is zero on a `Dormant` contract.** The pallet sets it only for `Active`
351
+ and `Subscription`, because the field doubles as the settlement clock and a `Dormant`
352
+ contract never settles. `index` is zero on every contract type. So neither field tells you
353
+ where a registered artifact lives — which matters, because a `ContractId` reference points
354
+ at exactly the contracts that lack it. The registration block survives in the deadline
355
+ instead:
356
+
357
+ ```ts
358
+ // ContractDeadlines[id] = registration_block + ContractDeadlineDuration,
359
+ // and a Dormant contract never settles, so the entry is never cleared.
360
+ const deadline = await api.query.compute.contractDeadlines(contractId);
361
+ const duration = await api.query.compute.contractDeadlineDuration();
362
+ const registrationBlock = BigInt(deadline.toString()) - BigInt(duration.toString());
363
+ ```
364
+
365
+ This is a derivation, not a record: it is wrong if root changed
366
+ `ContractDeadlineDuration` between registration and lookup.
367
+
368
+ ---
369
+
370
+ ## 4. Guardians
371
+
372
+ ### 4.1 Listing guardians
373
+
374
+ ```ts
375
+ const guardians = await getGuardianList(); // custom RPC: guardian.guardianList
376
+ const detail = await getGuardianParticipants(); // current + upcoming, with prefs and stake
377
+ ```
378
+
379
+ `getGuardianParticipants` reads `guardian.guardians` (active this era) and
380
+ `guardian.nextGuardians` (active next era). The guardian set rotates per era, so a
381
+ long-lived contract should name guardians present in both.
382
+
383
+ > `getGuardianParticipants` calls `disconnectApi()` in its `finally` block. It tears down
384
+ > the shared API connection on the way out — call it before other SDK work, not between
385
+ > two transactions.
386
+
387
+ ### 4.2 Joining as a guardian
388
+
389
+ `joinGuardian(account, prefs)` submits `staking.guard` with a `GuardianPrefs`. The chain
390
+ expects `fee_thresholds` as a list of `(ComputeType, rate)` pairs, one per compute type,
391
+ and `prefs.fee` accepts either shape:
392
+
393
+ ```ts
394
+ // One rate for every compute type named in `compute`
395
+ await joinGuardian(account, { compute: "tee,fhe", fee: 1_500_000_000_000_000_000n });
396
+ // -> fee_thresholds: [[Tee, 1.5e18], [Fhe, 1.5e18]]
397
+
398
+ // A rate per compute type
399
+ await joinGuardian(account, {
400
+ compute: "tee,fhe",
401
+ fee: { tee: 1_500_000_000_000_000_000n, fhe: 3_000_000_000_000_000_000n },
402
+ });
403
+ // -> fee_thresholds: [[Tee, 1.5e18], [Fhe, 3e18]]
404
+
405
+ // No thresholds — accepts any rate for every compute type
406
+ await joinGuardian(account, { compute: "tee", standard: true });
407
+ // -> fee_thresholds: []
408
+ ```
409
+
410
+ `fee` is in **atomic units**, not PALI — the CLI converts with `tokenToBigint` before
411
+ calling. Pricing a compute type absent from `compute` throws rather than being sent, and
412
+ duplicate types are impossible in either shape: `staking.guard` rejects them with
413
+ `InvalidGuardianPrefs`.
414
+
415
+ ### 4.3 Guardian groups — and why they are hard to find
416
+
417
+ **A guardian group has no on-chain storage.** Neither `dataAvailability.daccGuardianGroup`
418
+ nor `daccGuardianGroupInfo` writes anything; both only emit a `DaccGuardianGroup` event.
419
+ This single fact explains every difficulty around groups, so it is worth stating plainly:
420
+ there is no `api.query` that returns a group, and no way to enumerate groups from state.
421
+
422
+ What you get instead:
423
+
424
+ - **The group ID is derivable.** `group_id = blake2_256(concat(SCALE-encoded guardian
425
+ account IDs, in order))`. Order matters — the same guardians in a different order are a
426
+ different group.
427
+ - **The crypto parameters are not derivable.** `group_pk`, `tau_params` and `agg_key` come
428
+ from the guardian network's distributed key generation and exist only as arguments to
429
+ the `daccGuardianGroupInfo` extrinsic. To obtain them you must read that extrinsic back
430
+ out of its block.
431
+
432
+ So there are exactly two supported ways to get a `GuardianGroupInfo`:
433
+
434
+ ```ts
435
+ // 1. Create a group and watch for the follow-up extrinsic (needs 3 guardians exactly)
436
+ const info = await createGuardianGroupAndWatch(account, guardians);
437
+
438
+ // 2. Reconstruct one you created earlier, from the block+index you recorded at the time
439
+ const info = await getGuardianGroupInfo(
440
+ { blockNumber, index }, // the daccGuardianGroup creation extrinsic
441
+ undefined, // optional: the daccGuardianGroupInfo extrinsic; scanned for if omitted
442
+ );
443
+ ```
444
+
445
+ **Persist `{ blockNumber, index }` when you create a group.** It is the only handle that
446
+ lets you recover the group later. Losing it means scanning the chain, or creating a new
447
+ group. (The `sealed-bid-auction` demo stores exactly this in a `.group.json` file.)
448
+
449
+ Group creation is a **two-transaction protocol**: your `daccGuardianGroup` call registers
450
+ the intent, and the guardian network responds with a separate `daccGuardianGroupInfo`
451
+ transaction carrying the computed parameters, typically a few blocks later. `maxBlocks`
452
+ (default 20) bounds how long the SDK waits.
453
+
454
+ To *list* groups, index `DaccGuardianGroup` events or `daccGuardianGroupInfo` extrinsics
455
+ from block history yourself, and keep your own record. There is no chain-side index.
456
+
457
+ You only need a group for **threshold-encrypted** work (`encryptedInference*`, encrypted
458
+ DA uploads). Plaintext and trusted compute need a guardian *list*, not a group.
459
+
460
+ ---
461
+
462
+ ## 5. Compute results
463
+
464
+ Applications submit agreements; **node operators submit results.** If you are building an
465
+ app, this section is about what to wait for, not what to call.
466
+
467
+ `compute.result(request_id, contract, submitor, compute_duration_ms, execution_outcome)`
468
+ is submitted by `result_relay` after the orchestrator finishes a job. The SDK deliberately
469
+ ships no wrapper for it.
470
+
471
+ The rules that make results fail:
472
+
473
+ - **`compute_duration_ms` is bounded.** It must not exceed
474
+ `(elapsed_blocks + 4) × MillisecondsPerBlock`, measured from `invocation_block` (or
475
+ `origin_block` for non-subscriptions). Overstating duration fails with
476
+ `ComputeDurationTooLarge`. A very fast chain (500ms blocks) makes this bound tight.
477
+ - **The contract must exist.** `request_id` is the contract ID; an unknown one is
478
+ `AgreementNotFound`.
479
+ - **The `contract` argument must be re-supplied in full**, matching the original.
480
+ - **`CheckCompute` gates the extrinsic.** `compute.result` is one of a small allowlist of
481
+ calls permitted to carry a non-default `ComputePayload` (§8); anything else carrying one
482
+ is rejected as `ForbiddenCompute`.
483
+
484
+ To observe a result as an application, watch for these events on your contract ID:
485
+
486
+ | Event | Meaning |
487
+ |---|---|
488
+ | `compute.ComputeResult(request_id)` | A result landed |
489
+ | `compute.ExecutionSuccess` / `ExecutionFailed` / `ExecutionTerminated` | How the job ended |
490
+ | `compute.ExecutionFullSettlement` / `ExecutionPartialSettlement` | How the budget resolved |
491
+ | `compute.ContractSettled { refunded, reason }` | Contract closed, funds returned |
492
+ | `compute.SubmitorFeePaid` / `InputFeePaid` / `DecryptionFeePaid` | Who was paid |
493
+
494
+ `ComputeResult` and `ExecutionFailed` can both fire for the same request: a job that ran
495
+ and failed still consumed compute and still pays out.
496
+
497
+ ---
498
+
499
+ ## 6. Writing a compute image
500
+
501
+ This section is for whoever builds the Docker image a contract runs. Everything here is
502
+ enforced by the **orchestrator**, a sidecar every guardian node runs alongside its compute
503
+ node (`compute-core/orchestrator`). It watches the chain, resolves your program and inputs,
504
+ drives the container through `docker_gateway`, and submits the result. You never call it —
505
+ you satisfy its conventions.
506
+
507
+ ### 6.1 How your image is located
508
+
509
+ `contract.compute.program` decides both *what* runs and *how the image is obtained*. The
510
+ variants are not interchangeable:
511
+
512
+ | `program` | Image resolution |
513
+ |---|---|
514
+ | `{ Inline: { data } }` | Bytes are UTF-8 decoded into an **image reference** (`"myorg/app:v1.2.3"`) and pulled from a registry. This is the normal path |
515
+ | `{ Url: { url } }` | The URL is fetched and its body written to `<staging>/program/image-<b>-<e>.tar`, then loaded as a **local archive**. The URL must serve a `docker save` tarball, *not* a registry reference |
516
+ | `{ Ipfs: { cid } }` | Same as `Url`, fetched via the configured IPFS gateway |
517
+ | `{ NativeExecute: "Inference" }` | No container at all — routed to Ollama (§6.6) |
518
+
519
+ A registry reference passed as `Url` will be downloaded as if it were a tarball and fail.
520
+
521
+ ### 6.2 Inputs: `/input`, read-only
522
+
523
+ Every entry in `contract.compute.input` is resolved to bytes and written to a file before
524
+ your container starts:
525
+
526
+ ```
527
+ /input/<blockHeight>-<extrinsicIndex>-<i> read-only
528
+ /output/ read-write
529
+ ```
530
+
531
+ `<i>` is the zero-based position of the input. A single-input job gets exactly one file.
532
+ The host side lives at `<STAGING_DIR>/<blockHeight>-<extrinsicIndex>/`, and the whole tree
533
+ is deleted once the result is submitted.
534
+
535
+ Rules worth building around:
536
+
537
+ - **Do not hard-code the filename.** It embeds the block height and extrinsic index, which
538
+ you cannot know in advance. List `/input`, sort, and read — this is exactly what the
539
+ built-in inference path does.
540
+ - **Inputs arrive decrypted.** If `contract.compute.cipher` is not `"Plaintext"`, the
541
+ orchestrator decrypts before writing. Your container always sees plaintext.
542
+ - **Multi-input encrypted jobs are rejected**, because one `CipherSuite` carries one nonce
543
+ and it cannot be safely reused across inputs. Multiple inputs are fine when plaintext.
544
+ - All DA variants (`Inline`, `Url`, `Ipfs`, `ChainTransaction`, `ContractId`) are resolved
545
+ by the orchestrator. Your image only ever sees a file.
546
+
547
+ ### 6.3 Output: **stdout is the result**
548
+
549
+ This is the single most important rule, and the one most likely to surprise you:
550
+
551
+ > **The result submitted on-chain is your container's combined stdout + stderr.**
552
+
553
+ `/output` is mounted read-write, but **nothing reads it.** The orchestrator collects the
554
+ container's logs, and then deletes the entire staging tree — `/output` included. Writing
555
+ your result to a file there means submitting an empty result.
556
+
557
+ The consequences:
558
+
559
+ - **stdout and stderr are interleaved** into one stream in chronological order. Anything
560
+ you log for diagnostics — progress bars, warnings, a stray library banner on stderr —
561
+ is concatenated into the on-chain result. Emit the result and nothing else; send
562
+ diagnostics nowhere, or accept that they become part of your output.
563
+ - **Output is read as UTF-8 text.** Binary written to stdout is decoded lossily and
564
+ corrupted. Base64- or hex-encode anything that is not text.
565
+ - **Keep it small.** The result is embedded *inline* in the `compute.result` extrinsic, so
566
+ it is bounded by extrinsic and block size limits — far below the gateway's 100 MiB log
567
+ ceiling. That ceiling protects the host's disk; it is not a budget for your result.
568
+ - **Encryption is applied for you.** If `contract.result_cipher` is not `"Plaintext"`, the
569
+ orchestrator encrypts your bytes to the requester before submission. Emit plaintext.
570
+
571
+ ### 6.4 Exit codes and execution outcome
572
+
573
+ Your exit code selects the `ExecutionOutcome` reported on-chain (§5):
574
+
575
+ | Container ends with | `ExecutionOutcome` |
576
+ |---|---|
577
+ | Exit code `0` | `Success` |
578
+ | Any non-zero exit code | `Failed` |
579
+ | Exceeds its timeout (container is force-removed) | `Terminated` |
580
+ | Orchestrator cannot determine the outcome | `Failed` |
581
+
582
+ **A failed run still submits a result and is still billed.** Compute time is metered from
583
+ container start regardless of outcome, so a crash costs the requester real money. Exit
584
+ non-zero to signal failure honestly — but note the logs captured up to that point are what
585
+ gets submitted as the result.
586
+
587
+ ### 6.5 What you cannot rely on
588
+
589
+ Two fields look available from the contract but do not currently reach your container:
590
+
591
+ - **Environment variables.** `ComputeInfo.programEnv` exists on-chain, but the orchestrator
592
+ reads `env` from a top-level extrinsic argument that `compute.agreement` does not have.
593
+ A chain-submitted job therefore runs with **no environment variables** from the contract.
594
+ - **Port publications.** The orchestrator looks for `ports` on the compute step, but
595
+ `ComputeInfo` has no such field. Ports are reachable only through the orchestrator's
596
+ direct HTTP job API, not from an on-chain contract.
597
+
598
+ Design your image to take everything it needs from `/input`.
599
+
600
+ Also note that `contract.compute.deadline` is documented on-chain as a **block number**
601
+ (§3.3) but is consumed by the orchestrator as a **timeout in seconds**, defaulting to 300
602
+ when zero or absent. Until that is reconciled, treat it as your container's wall-clock
603
+ budget in seconds.
604
+
605
+ ### 6.6 The built-in inference path
606
+
607
+ `{ NativeExecute: "Inference" }` runs no image of yours. The orchestrator reads the **first**
608
+ input file as a complete OpenAI-compatible `/v1/chat/completions` request body, POSTs it to
609
+ Ollama, and submits the raw JSON response as the result. Supply a full request body as your
610
+ input — not a bare prompt. `"ContractAccess"` is defined on-chain but not implemented by the
611
+ orchestrator; any other `NativeExecute` command is rejected.
612
+
613
+ ### 6.7 Minimal example
614
+
615
+ ```dockerfile
616
+ FROM python:3.12-slim
617
+ COPY main.py /main.py
618
+ ENTRYPOINT ["python", "/main.py"]
619
+ ```
620
+
621
+ ```python
622
+ import os, sys, json, base64
623
+
624
+ input_dir = "/input"
625
+ files = sorted(os.listdir(input_dir)) # never hard-code the filename
626
+ with open(os.path.join(input_dir, files[0]), "rb") as f:
627
+ payload = f.read()
628
+
629
+ result = {"length": len(payload), "sha": base64.b64encode(payload[:8]).decode()}
630
+
631
+ # The result is stdout. Nothing else may be printed - not even to stderr.
632
+ sys.stdout.write(json.dumps(result))
633
+ sys.exit(0)
634
+ ```
635
+
636
+ ---
637
+
638
+ ## 7. Known drift — read before debugging
639
+
640
+ Verified against the current `dev` branches at the time of writing. These are real
641
+ inconsistencies between the repositories, not documentation gaps.
642
+
643
+ 1. **`joinGuardian` now tracks the `dev` runtime, not the deployed one.** `spec.ts` and
644
+ `GuardianJoinPrefs` were updated together to `fee_thresholds: Vec<(ComputeType, u128)>`,
645
+ matching `dev`. A runtime predating that change has either a scalar `fee_threshold` or
646
+ no threshold field at all, and encoding against it fails or silently drops the value —
647
+ check which runtime you target before debugging a threshold that did not take effect.
648
+
649
+ Note that extrinsic arguments are encoded from *metadata*, not from `spec.ts` —
650
+ `api.tx.staking.guard` resolves `prefs` through the metadata lookup id, so the
651
+ `GuardianPrefs` entry in `spec.ts` is documentation, and the *shape* `joinGuardian`
652
+ passes is what actually has to match.
653
+
654
+ The general rule this illustrates: **entries in `spec.ts` that duplicate a metadata type
655
+ are inert** — metadata wins for everything it describes. Only types reached by *name*
656
+ (custom RPCs in `API_RPC`, signed extensions in `API_EXTENSIONS`) are load-bearing.
657
+
658
+ 2. **`MillisecondsPerBlock` is not in metadata.** It is a plain `Get<u64>` on
659
+ `pallet_compute::Config`, not a `#[pallet::constant]`, so `api.consts.compute` does not
660
+ expose it. `getFeeParams()` reads `babe.expectedBlockTime` instead — the runtime wires
661
+ both to the same `MILLISECS_PER_BLOCK`. Adding `#[pallet::constant]` would make this
662
+ exact rather than inferred.
663
+
664
+ 3. **The orchestrator assumes 6000ms blocks.** `computeMaxRunningTimeSecs` in
665
+ `orchestrator/src/chain.ts` hard-codes `blockTimeMs = 6000`, while the runtime's
666
+ `MILLISECS_PER_BLOCK` is `500`. Treat its running-time estimates as unreliable.
667
+
668
+ 4. **`scripts/test-compute-result.mjs` is stale.** It calls `compute.result` with three
669
+ arguments; the extrinsic now takes five.
670
+
671
+ 5. **`ComputeInfo` has fields the SDK helpers omit.** `program_env` and `metadata` exist
672
+ on the chain struct but are not set by `simpleCompute`, `inferenceCompute` or
673
+ `dataContract`. Supply them explicitly if you need them.
674
+
675
+ 6. **`deadline` means two different things.** `ComputeInfo.deadline` is documented on-chain
676
+ as a block number and used as one by `compute.invoke`'s expiry check, but the
677
+ orchestrator reads the same field as a **timeout in seconds** when running a container.
678
+ At 500ms blocks, a value meant as N blocks (N/2 seconds) becomes an N-second container
679
+ budget — twice the intended window.
680
+
681
+ 7. **`/output` is a dead mount.** The orchestrator bind-mounts
682
+ `<staging>/output` at `/output` read-write, then never reads it and deletes the staging
683
+ tree after submission. The result channel is stdout (§6.3). Either the mount should be
684
+ removed or it should be collected — as it stands it silently invites data loss.
685
+
686
+ 8. **`programEnv` and ports never reach the container.** The orchestrator sources `env` and
687
+ `ports` from top-level extrinsic arguments that `compute.agreement` does not define, so
688
+ the on-chain `ComputeInfo.programEnv` field is inert (§6.5).
689
+
690
+ ---
691
+
692
+ ## 8. The `ComputePayload` signer option
693
+
694
+ Several SDK calls pass an `opts` object into `signAndSend` that is neither a normal
695
+ extrinsic argument nor a standard signer option:
696
+
697
+ ```ts
698
+ { compute: { daType: 1, verification: 0, compute: 1 } }
699
+ ```
700
+
701
+ This is `ComputePayload`, extra data carried by the `CheckCompute` **signed extension** —
702
+ it travels with the transaction and is validated before dispatch. `CheckCompute` requires
703
+ it to be *default/empty* for every call except an allowlist: `dataAvailability.submitData`,
704
+ `compute.agreement`, `compute.result`, `compute.invoke`, and
705
+ `guardSession.agreementResponse`. Attaching a non-default payload to any other call is
706
+ rejected as `ForbiddenCompute`.
707
+
708
+ | Field | Meaning |
709
+ |---|---|
710
+ | `daType` | `0` none, `1` DA, `4` compute request |
711
+ | `compute` | `0` dormant, `1` active |
712
+ | `verification` | Verification mode |
713
+ | `agreement` | 32-byte guardian peer keys (base58-decode the peer ID, drop the first 6 bytes) |
714
+
715
+ The SDK sets this for you in `createAgreement`, `submitData` and the encrypted-inference
716
+ helpers. You need to construct one by hand only when calling `api.tx` directly.
717
+
718
+ ---
719
+
720
+ ## 9. Failure reference
721
+
722
+ | Symptom | Cause |
723
+ |---|---|
724
+ | `InsufficientFreeBalance` | `fees` below the floor (§2.2), or free balance below `fees`. Call `estimateMinFee`. |
725
+ | `ZeroComputeRate` | `computeRate` is 0 on an `Active`/`Subscription` contract. Only `Dormant` may be 0. |
726
+ | `AgreementFailed` event, nothing reserved | A guardian rejected the offer — `computeRate` under its threshold (§2.4). |
727
+ | `ForbiddenCompute` | Non-default `ComputePayload` on a call not in the allowlist (§8). |
728
+ | `Invalid: Custom(149)` | `guardians` is empty while `compute.program` is not `"Null"` (§3.2). |
729
+ | `Invalid: Custom(145)` | Duplicate entries in `guardians` (§3.2). |
730
+ | `Invalid: Custom(148)` | A named guardian is not registered/staked (§3.2). |
731
+ | Opaque decode failure on `agreement` | Wrong field shape — snake_case keys, or a missing enum payload (§3). |
732
+ | `ComputeDurationTooLarge` | Reported duration exceeds `(elapsed_blocks + 4) × block_ms` (§5). |
733
+ | `InvalidContractType` | `invoke` called on a non-`Subscription` contract. |
734
+ | `ContractSettled` | Contract already settled — budget exhausted or deadline passed. |
735
+ | `AgreementNotFound` | Unknown contract ID, or a `Subscription` with no settlement record. |
736
+ | `ContractExpired` | Past the contract deadline. |
737
+ | `invoke` returns Ok but nothing runs | Deadline or budget check settled the contract instead (§3.6). |
738
+ | `compute.agreement` hangs, never included, no error | A named guardian has not submitted its `agreement_response`; the tx is parked in the future queue (§2.1). |
739
+ | Result is empty on-chain | The image wrote to `/output`; only stdout is collected (§6.3). |
740
+ | Result contains log noise or banners | stderr is interleaved into stdout (§6.3). |
741
+ | Result is corrupted / mojibake | Binary written to stdout; it is decoded as UTF-8 (§6.3). |
742
+ | Container sees no environment variables | `programEnv` is not plumbed through (§6.5). |
743
+ | Image pull fails for a `Url` program | `Url` expects a `docker save` tarball, not a registry reference (§6.1). |
744
+ | Group info cannot be found | No on-chain storage for groups; you need the creation block+index (§4.3). |
745
+ | API disconnects mid-flow | `getGuardianParticipants` disconnects the shared API on exit (§4.1). |
746
+
747
+ ---
748
+
749
+ ## 10. Conventions
750
+
751
+ - **Amounts.** PALI has 18 decimals. `Fee.amount` and `Fee.computeRate` take *human* PALI
752
+ values (`"1.5"`); `buildFee` converts via `toAtomicPaliAmount`. Everything read back off
753
+ the chain is in atomic units — convert with `fromAtomicPaliAmount`.
754
+ - **`computeRate` is per millisecond**, not per block or per second.
755
+ - **`deadline: 0` means no deadline.**
756
+ - **`computerIndices`** indexes into `contract.guardians`, as does `Trusted { trust_index }`.
757
+ - **Finality.** `signAndSend` resolves at in-block by default; pass
758
+ `init({ txWaitFinalization: true })` to wait for finalization.
759
+ - **Currency.** `currencyId` defaults to `"Native"`; the deposit is reserved in and settled
760
+ from that currency.