@ottochain/sdk 2.6.0 → 2.7.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Message-layer advisory lint for an {@link ApplyMorphism} transaction (asset-model.md §7; chain
3
+ * audit finding C2, enforced in `AssetCombiner`).
4
+ *
5
+ * Purpose
6
+ * -------
7
+ * The definition-scoped {@link ../schema/guard-lint.ts | guard-lint} walks fiber-app DEFINITIONS; it
8
+ * never sees an `ApplyMorphism` MESSAGE, so a malformed / no-consent `Compose` slips past it. This
9
+ * linter closes that gap at the transaction-builder layer: hand it the `ApplyMorphism` you are about
10
+ * to sign and it flags the two classes of C2 mistake the chain rejects.
11
+ *
12
+ * The chain rule being mirrored (C2, `Compose`/`Pool` only):
13
+ * - `otherAssetIds` must be free of duplicates and must NOT include the source `assetId`
14
+ * (the old self/duplicate-inflation path — rejected unconditionally, holder-independent).
15
+ * - every counter-party the signer does NOT hold requires a live {@link AuthorizeCompose} `nonce`;
16
+ * a same-holder / all-signer-owned compose needs none.
17
+ *
18
+ * ERRORS are statically certain (the chain rejects regardless of who holds what). WARNINGS are
19
+ * heuristic — the signer's holdings/ownership are unknown at build time, so a missing consent nonce
20
+ * cannot be proven fatal (a same-holder compose is legitimately nonce-less).
21
+ *
22
+ * This is a PURE, non-throwing advisory. It is deliberately NOT wired into
23
+ * {@link createApplyMorphismPayload} (which must stay non-breaking and side-effect-free); call it
24
+ * explicitly before signing, or in your app's own pre-flight checks.
25
+ */
26
+ /** Stable rule codes for {@link lintApplyMorphism} — referenced by tests and report tooling. */
27
+ export const MORPHISM_LINT_CODES = {
28
+ /** `otherAssetIds` contains a duplicate id (chain rejects — inflation path). */
29
+ DUPLICATE_OTHER_ID: 'morphism-duplicate-other-id',
30
+ /** `otherAssetIds` includes the source `assetId` (self-composition — chain rejects). */
31
+ SELF_COMPOSE: 'morphism-self-compose',
32
+ /** `otherAssetIds` is non-empty but `kind` ignores it (not Compose/Pool — likely a mistake). */
33
+ OTHER_IDS_IGNORED: 'morphism-other-ids-ignored',
34
+ /** Compose/Pool with counter-parties and no consent `nonce` (rejected IF any part is not signer-owned). */
35
+ COMPOSE_NO_NONCE: 'morphism-compose-no-nonce',
36
+ };
37
+ /** The morphism kinds whose semantics READ `otherAssetIds` (fold counter-party parts into a composite). */
38
+ const COMPOSING_KINDS = new Set(['COMPOSE', 'POOL']);
39
+ function finding(severity, code, message, path) {
40
+ return { severity, code, message, path };
41
+ }
42
+ /**
43
+ * Lint a single {@link ApplyMorphism} message. Pure and non-throwing. Returns every finding
44
+ * (`error` + `warn`); callers decide the policy (e.g. block on any `error`, surface `warn`s).
45
+ *
46
+ * ERRORS (statically certain — the chain rejects unconditionally, holder-independent):
47
+ * - {@link MORPHISM_LINT_CODES.DUPLICATE_OTHER_ID} — `otherAssetIds` contains a duplicate id.
48
+ * - {@link MORPHISM_LINT_CODES.SELF_COMPOSE} — `otherAssetIds` includes the source `assetId`.
49
+ * - {@link MORPHISM_LINT_CODES.OTHER_IDS_IGNORED} — `otherAssetIds` is non-empty but `kind` is not
50
+ * `COMPOSE`/`POOL` (the field is ignored for other kinds — a likely authoring mistake).
51
+ *
52
+ * WARNINGS (heuristic — holder/ownership unknown at build time):
53
+ * - {@link MORPHISM_LINT_CODES.COMPOSE_NO_NONCE} — a `COMPOSE`/`POOL` with a non-empty
54
+ * `otherAssetIds` and NO `nonce`. IF any counter-party is not signer-owned the chain rejects it
55
+ * (C2); attach the reveal half of an {@link AuthorizeCompose} handshake as `nonce`. A same-holder
56
+ * compose (all parts signer-owned) is legitimately nonce-less — hence a warning, not an error.
57
+ */
58
+ export function lintApplyMorphism(msg) {
59
+ const out = [];
60
+ const ids = msg.otherAssetIds ?? [];
61
+ const isComposing = COMPOSING_KINDS.has(msg.kind);
62
+ // ---- error: duplicate ids in otherAssetIds ---------------------------------
63
+ // One finding per DISTINCT duplicated id (path points at its first repeat occurrence).
64
+ const seen = new Set();
65
+ const dupReported = new Set();
66
+ ids.forEach((id, i) => {
67
+ if (seen.has(id) && !dupReported.has(id)) {
68
+ dupReported.add(id);
69
+ out.push(finding('error', MORPHISM_LINT_CODES.DUPLICATE_OTHER_ID, `otherAssetIds contains duplicate id "${id}". The chain rejects a Compose/Pool whose ` +
70
+ `otherAssetIds has duplicates (the old inflation path). List each counter-party asset once.`, `otherAssetIds[${i}]`));
71
+ }
72
+ seen.add(id);
73
+ });
74
+ // ---- error: otherAssetIds includes the source assetId (self-composition) ----
75
+ const selfIdx = ids.indexOf(msg.assetId);
76
+ if (selfIdx !== -1) {
77
+ out.push(finding('error', MORPHISM_LINT_CODES.SELF_COMPOSE, `otherAssetIds includes the source assetId "${msg.assetId}" (self-composition). The chain ` +
78
+ `rejects this unconditionally. otherAssetIds must list only the OTHER parts folded into the composite.`, `otherAssetIds[${selfIdx}]`));
79
+ }
80
+ // ---- error: otherAssetIds set but kind ignores it ---------------------------
81
+ if (ids.length > 0 && !isComposing) {
82
+ out.push(finding('error', MORPHISM_LINT_CODES.OTHER_IDS_IGNORED, `otherAssetIds is non-empty but kind is "${msg.kind}", which ignores otherAssetIds entirely ` +
83
+ `(only COMPOSE/POOL fold in counter-party parts). This is almost certainly a mistake — set ` +
84
+ `kind to COMPOSE/POOL, or drop otherAssetIds.`, 'otherAssetIds'));
85
+ }
86
+ // ---- warn: composing with counter-parties but no consent nonce --------------
87
+ // nonce === 0 is a valid live nonce; only an OMITTED nonce (undefined/null) triggers the warning.
88
+ if (isComposing && ids.length > 0 && msg.nonce == null) {
89
+ out.push(finding('warn', MORPHISM_LINT_CODES.COMPOSE_NO_NONCE, `${msg.kind} folds in otherAssetIds but carries no consent nonce. If ANY counter-party asset ` +
90
+ `is not held by the signer, the chain REJECTS this (C2): attach the reveal half of an ` +
91
+ `AuthorizeCompose handshake as \`nonce\`. A same-holder compose (all parts signer-owned) ` +
92
+ `needs none, so this is advisory.`, 'nonce'));
93
+ }
94
+ return out;
95
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Light-client verification of committed state proofs.
3
+ *
4
+ * The chain's `/state-machines|scripts|assets/{id}/state-proof` endpoints return a
5
+ * {@link StateProof}: the record plus a Merkle-Patricia inclusion proof against `mptRoot`,
6
+ * whose combined hash IS the snapshot's consensus-signed `calculatedStateProof`. Verifying
7
+ * the proof locally means trusting NO serving node — only snapshot consensus.
8
+ *
9
+ * The fold mirrors metakit's `MerklePatriciaVerifier` byte-for-byte (validated against live
10
+ * cluster proofs):
11
+ *
12
+ * - the response `witness` is LEAF-FIRST; verification walks it reversed (root-first);
13
+ * - every node's digest = `sha256(typePrefix ++ utf8(JCS(dropNulls(contents))))` over the
14
+ * commitment's SUBTYPE (contents-only) encoding — prefixes: leaf `0x00`, branch `0x01`,
15
+ * extension `0x02`;
16
+ * - a Branch consumes one path nibble via `pathsDigest`; an Extension consumes its `shared`
17
+ * nibbles and continues at `childDigest`;
18
+ * - the terminal Leaf must match the remaining path (`remaining`) and bind
19
+ * `dataDigest == sha256(JCS(dropNulls(record)))` (no prefix);
20
+ * - the trie path of a committed key is `hex(utf8(key))` (metakit `CommitKey.toHex`), e.g.
21
+ * `fiber/<uuid>` → `66696265722f…`.
22
+ *
23
+ * NOTE: this intentionally does NOT route through the JLVM `mpt_verify` opcode — the
24
+ * TypeScript port of that opcode mis-canonicalizes non-scalar leaf values (fiber records)
25
+ * and returns false; its conformance vectors only cover scalar leaves. The Scala (chain)
26
+ * side is correct. Once the port is fixed upstream this helper stays valid either way: it
27
+ * is the same algorithm without the JsonLogic value-domain round-trip.
28
+ */
29
+ import { canonicalize } from '@constellation-network/metagraph-sdk';
30
+ import { sha256 } from '@noble/hashes/sha256.js';
31
+ const utf8 = (s) => new TextEncoder().encode(s);
32
+ const toHexStr = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
33
+ /** `sha256(prefixByte? ++ utf8(JCS(dropNulls(value))))` — the chain's content-digest rule. */
34
+ function jcsSha256(value, prefix) {
35
+ const canonical = utf8(canonicalize(value));
36
+ if (prefix === undefined)
37
+ return toHexStr(sha256(canonical));
38
+ const framed = new Uint8Array(1 + canonical.length);
39
+ framed[0] = prefix;
40
+ framed.set(canonical, 1);
41
+ return toHexStr(sha256(framed));
42
+ }
43
+ /** The MPT trie path of a committed key — `hex(utf8(key))` (metakit `CommitKey.toHex`). */
44
+ export function commitKeyPath(key) {
45
+ return toHexStr(utf8(key));
46
+ }
47
+ const NODE_PREFIX = { Leaf: 0, Branch: 1, Extension: 2 };
48
+ /**
49
+ * Verify a Merkle-Patricia inclusion proof: `record` is committed at `key` under `mptRoot`.
50
+ *
51
+ * Pure and side-effect free; never throws on malformed input (returns `{ ok: false }`).
52
+ */
53
+ export function verifyMptInclusion(mptRoot, key, record, proof) {
54
+ const fail = (reason) => ({ ok: false, reason });
55
+ if (!proof || typeof proof.path !== 'string' || !Array.isArray(proof.witness) || proof.witness.length === 0)
56
+ return fail('malformed proof (missing path/witness)');
57
+ const expectedPath = commitKeyPath(key);
58
+ if (proof.path.toLowerCase() !== expectedPath)
59
+ return fail(`trie path != hex(utf8("${key}"))`);
60
+ let digest = String(mptRoot).replace(/^0x/, '').toLowerCase();
61
+ let path = proof.path.toLowerCase();
62
+ const nodes = [...proof.witness].reverse(); // response is leaf-first; fold root-first
63
+ for (const [depth, node] of nodes.entries()) {
64
+ const isLast = depth === nodes.length - 1;
65
+ const prefix = NODE_PREFIX[node?.type];
66
+ if (prefix === undefined)
67
+ return fail(`unknown witness node type '${String(node?.type)}'`);
68
+ if (jcsSha256(node.contents, prefix) !== digest)
69
+ return fail(`${node.type.toLowerCase()} commitment mismatch at depth ${depth}`);
70
+ if (node.type === 'Branch') {
71
+ const child = node.contents.pathsDigest?.[path[0]];
72
+ if (!child)
73
+ return fail(`branch has no child at nibble '${path[0]}' (depth ${depth})`);
74
+ digest = child.toLowerCase();
75
+ path = path.slice(1);
76
+ }
77
+ else if (node.type === 'Extension') {
78
+ const shared = String(node.contents.shared ?? '');
79
+ if (!path.startsWith(shared.toLowerCase()))
80
+ return fail(`extension shared-nibble mismatch at depth ${depth}`);
81
+ path = path.slice(shared.length);
82
+ digest = String(node.contents.childDigest ?? '').toLowerCase();
83
+ }
84
+ else {
85
+ // Leaf
86
+ if (!isLast)
87
+ return fail('leaf before end of witness');
88
+ if (String(node.contents.remaining ?? '').toLowerCase() !== path)
89
+ return fail('leaf remaining-path mismatch');
90
+ if (String(node.contents.dataDigest ?? '').toLowerCase() !== jcsSha256(record))
91
+ return fail('leaf dataDigest != sha256(JCS(record))');
92
+ return { ok: true };
93
+ }
94
+ }
95
+ return fail('witness does not terminate in a leaf');
96
+ }
97
+ /**
98
+ * Verify a whole {@link StateProof} response for a committed key.
99
+ *
100
+ * Checks the trie inclusion of `record` at `key` under the response's `mptRoot` (see
101
+ * {@link verifyMptInclusion}). The caller supplies the key it EXPECTS (`fiber/<id>`,
102
+ * `script/<id>`, `asset/<id>`, …) so a proof for some other record cannot be substituted;
103
+ * the response's own `key` field must agree.
104
+ *
105
+ * Trust anchor: `committedRoot` (= `sha256(mptRoot ++ catalogRoot)`) rides in the
106
+ * consensus-signed snapshot as `calculatedStateProof`; binding `mptRoot` to a snapshot is
107
+ * the caller's (or a snapshot-following light client's) responsibility.
108
+ */
109
+ export function verifyStateProof(resp, expectedKey) {
110
+ if (resp.key !== expectedKey)
111
+ return { ok: false, reason: `response key '${resp.key}' != expected '${expectedKey}'` };
112
+ return verifyMptInclusion(String(resp.mptRoot), expectedKey, resp.record, resp.proof);
113
+ }
@@ -149,7 +149,13 @@ export function createAssetPolicyPayload(params) {
149
149
  export function createMintAssetPayload(params) {
150
150
  return { MintAsset: params };
151
151
  }
152
- /** Wrap an {@link ApplyMorphism} (apply a typed morphism to an asset instance). */
152
+ /**
153
+ * Wrap an {@link ApplyMorphism} (apply a typed morphism to an asset instance).
154
+ *
155
+ * @see lintApplyMorphism (`./morphism-lint.ts`) — pure, non-throwing advisory that flags the
156
+ * Compose/Pool mistakes the chain rejects (duplicate / self-referential `otherAssetIds`; a
157
+ * cross-holder compose with no consent `nonce`). Run it on `params` before signing.
158
+ */
153
159
  export function createApplyMorphismPayload(params) {
154
160
  return { ApplyMorphism: params };
155
161
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Webhook notification PAYLOAD types — the server-initiated push the chain POSTs to a subscribed
3
+ * callback, NOT part of the client's request surface.
4
+ *
5
+ * These are hand-authored (not generated from the OpenAPI contract) because the push is an OUTBOUND
6
+ * POST from the metagraph to the subscriber's callback URL — it is not an endpoint the chain's tapir
7
+ * `ApiEndpoints` serves, so it never appears in `openapi/ottochain-openapi-ml0.json`. They mirror the
8
+ * chain's shared network DTOs byte-for-byte:
9
+ *
10
+ * chain: modules/models/src/main/scala/xyz/kd5ujc/schema/api/webhooks/Subscriber.scala
11
+ * (SnapshotNotification / NotificationStats / SnapshotRejection)
12
+ * emit: modules/l0/.../webhooks/WebhookDispatcher.scala — `notification.asJson.noSpaces`
13
+ *
14
+ * Field-shape notes tied to the chain source:
15
+ * - The dispatcher serializes with a plain derived encoder (`.asJson`, NO `dropNullValues`), so an
16
+ * `Option[Long]` that is `None` is emitted as an explicit `null` — the KEY is always present, the
17
+ * VALUE is nullable. Hence `number | null` (a required key), not `number | undefined` (an optional
18
+ * key). Consumers must handle `null`.
19
+ * - `Long` → JSON number; `Instant` → ISO-8601 string; `UUID` → string.
20
+ * - The dispatcher only ever emits `"snapshot.finalized"` today (there is a dead, never-dispatched
21
+ * `RejectionNotification`/`transaction.rejected` type in the chain models — deliberately NOT mirrored
22
+ * here, because the SDK should model only what is actually sent). Rejections now ride the finalized
23
+ * snapshot's `rejections[]`, drained from its committed `RejectionReceipt`s.
24
+ *
25
+ * @see modules/models/.../schema/api/webhooks/Subscriber.scala
26
+ * @see modules/l0/.../webhooks/WebhookDispatcher.scala
27
+ * @packageDocumentation
28
+ */
29
+ /** The only event the chain's `WebhookDispatcher` currently emits. */
30
+ export const SNAPSHOT_FINALIZED_EVENT = 'snapshot.finalized';
@@ -112,11 +112,20 @@ export const triggers = (ts) => ({
112
112
  * MUST be a literal {@link ProtoStateMachineDefinition} (e.g. a nested `machine().wireDefinition()` /
113
113
  * `toProtoDefinition(child)` output) — NOT an expression the engine would evaluate at runtime.
114
114
  *
115
- * F8 gotcha — `owners` is load-bearing. A spawned child's transitions are gated by
116
- * `owners authorizedSigners` (`riverdale-economy/README.md`), so EVERY party that will later drive
117
- * the child (e.g. every bidder on a spawned auction) MUST be listed in `owners`, or their events are
118
- * rejected. `owners` may be a literal id array or an expression (e.g. `{ var: "event.auctionOwners" }`);
119
- * `childId` / `initialData` likewise accept literals or expressions.
115
+ * H1 constraintchild `owners` MUST be a SUBSET of the SPAWNING PARENT fiber's `owners`. As of the
116
+ * fiber-engine permissionless-hardening (chain branch `fix/fiber-engine-permissionless-hardening`, audit
117
+ * finding H1) the chain FAILS-CLOSED it aborts the whole transition under EVERY `spawnOwnerPolicy` if
118
+ * a `_spawn`'s resolved child `owners` are not the parent's owners. Assigning arbitrary, unsigned owners
119
+ * (the old "auction owned by all its bidders" pattern, where the bidders are NOT parent owners) is no
120
+ * longer valid: those events are not silently rejected, the spawn itself is rejected.
121
+ *
122
+ * So DO NOT try to enroll later participants by listing them here. A spawned child's transitions are gated
123
+ * by `owners ∪ authorizedSigners` for the DIRECT (wallet-signed) path; admit additional drivers through
124
+ * the child's OWN transitions (`authorizedSigners`), or drive it via the cascade (`_triggers`) subject to
125
+ * its `acceptedCallers` — never by widening `owners` beyond the parent's set. `owners` may be a literal id
126
+ * array or an expression (e.g. `{ var: "state.owners" }` — resolve it WITHIN the parent's owners), but a
127
+ * value the parent does not own (e.g. `{ var: "event.auctionOwners" }` supplied by the caller) will be
128
+ * rejected on-chain. `childId` / `initialData` likewise accept literals or expressions.
120
129
  */
121
130
  export const spawn = (ds) => ({ _spawn: ds });
122
131
  /**
@@ -32,6 +32,11 @@
32
32
  *
33
33
  * leading-dot — `{"var":".foo"}` resolves to null on chain.
34
34
  *
35
+ * H1 — a `_spawn` whose child `owners` are not provably a SUBSET of the spawning
36
+ * parent's owners. The chain now fails-closed (aborts the transition) under
37
+ * every spawnOwnerPolicy when child owners ⊄ parent owners; this advisory
38
+ * flags the strong not-subset signals (hardcoded / `event.*`-derived owners).
39
+ *
35
40
  * This is a STANDALONE validator. It is deliberately NOT wired into
36
41
  * `defineFiberApp` / `toProtoDefinition`: doing so would break the build until
37
42
  * every app is remediated. Run it via `scripts/lint-apps.mjs`.
@@ -50,6 +55,7 @@ export const LINT_CODES = {
50
55
  WITNESS_IN_TRANSITION: 'witness-in-transition', // rule 3
51
56
  DROPPED_DIRECTIVE: 'dropped-directive', // rule 4 (A3)
52
57
  LEADING_DOT_VAR: 'leading-dot-var', // rule 5
58
+ SPAWN_OWNERS: 'spawn-owners', // rule 6 (H1) — child owners not provably ⊆ parent
53
59
  };
54
60
  // =============================================================================
55
61
  // Rule data
@@ -287,6 +293,101 @@ function lintTransitionStructure(t, app, transition, path) {
287
293
  return out;
288
294
  }
289
295
  // =============================================================================
296
+ // _spawn owner-subset rule (rule 6 / H1)
297
+ // =============================================================================
298
+ /** Collect every string `var` reference (path or `[path, default]` head) at or below `node`. */
299
+ function collectVarRefs(node, acc) {
300
+ if (Array.isArray(node)) {
301
+ node.forEach((child) => collectVarRefs(child, acc));
302
+ return;
303
+ }
304
+ if (node === null || typeof node !== 'object')
305
+ return;
306
+ const obj = node;
307
+ const keys = Object.keys(obj);
308
+ if (keys.length === 1 && keys[0] === 'var') {
309
+ const ref = obj.var;
310
+ if (typeof ref === 'string')
311
+ acc.push(ref);
312
+ else if (Array.isArray(ref)) {
313
+ if (typeof ref[0] === 'string')
314
+ acc.push(ref[0]);
315
+ if (ref.length > 1)
316
+ collectVarRefs(ref[1], acc);
317
+ }
318
+ return;
319
+ }
320
+ for (const k of keys)
321
+ collectVarRefs(obj[k], acc);
322
+ }
323
+ /**
324
+ * Classify a `_spawn` child `owners` expression for the H1 subset risk. Returns a short reason string when
325
+ * the owners are NOT provably ⊆ the parent's owners from the definition alone, else `undefined`.
326
+ *
327
+ * The linter cannot see the parent fiber's runtime `owners` (they are set at `create()` time, not in the
328
+ * definition), so it flags only the two STRONG "not-subset" signals — keeping false positives low:
329
+ * - a literal array carrying ≥1 concrete address string (a hardcoded external owner list); and
330
+ * - any `event.*` reference (caller-supplied — the exact H1 owner-forgery vector).
331
+ * Owners derived purely from the parent's own `state.*` / `machineId` / `$`-context are assumed in-set and
332
+ * are NOT flagged.
333
+ */
334
+ function spawnOwnersRisk(owners) {
335
+ if (Array.isArray(owners) && owners.some((o) => typeof o === 'string' && o.length > 0)) {
336
+ return 'a literal array with hardcoded owner address(es)';
337
+ }
338
+ const refs = [];
339
+ collectVarRefs(owners, refs);
340
+ if (refs.some((r) => r === 'event' || r.startsWith('event.'))) {
341
+ return "an 'event' reference (caller-supplied owners)";
342
+ }
343
+ return undefined;
344
+ }
345
+ /**
346
+ * rule 6 (H1) — scan a transition effect for `_spawn` directives whose child `owners` are not provably a
347
+ * SUBSET of the spawning parent's owners. The chain now fails-closed (aborts the transition) under every
348
+ * `spawnOwnerPolicy` when a child's resolved owners are not ⊆ the parent's. Advisory (warn): the linter
349
+ * cannot prove the subset statically, so it flags only the strong not-subset signals (see
350
+ * {@link spawnOwnersRisk}).
351
+ */
352
+ function lintSpawnOwners(node, app, transition, path) {
353
+ const out = [];
354
+ if (Array.isArray(node)) {
355
+ node.forEach((child, i) => out.push(...lintSpawnOwners(child, app, transition, `${path}[${i}]`)));
356
+ return out;
357
+ }
358
+ if (node === null || typeof node !== 'object')
359
+ return out;
360
+ const obj = node;
361
+ for (const [k, v] of Object.entries(obj)) {
362
+ if (k === '_spawn' && Array.isArray(v)) {
363
+ v.forEach((entry, i) => {
364
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && 'owners' in entry) {
365
+ const reason = spawnOwnersRisk(entry.owners);
366
+ if (reason) {
367
+ out.push({
368
+ app,
369
+ transition,
370
+ severity: 'warn',
371
+ code: LINT_CODES.SPAWN_OWNERS,
372
+ message: `_spawn child 'owners' must be a SUBSET of the spawning parent fiber's owners — the chain ` +
373
+ `now FAILS-CLOSED (aborts the transition) under every spawnOwnerPolicy if they are not ⊆ the ` +
374
+ `parent's (audit H1). This 'owners' is ${reason} and cannot be proven ⊆ the parent. Admit ` +
375
+ `later participants via the child's own transitions / authorizedSigners / acceptedCallers ` +
376
+ `instead of listing non-parent owners.`,
377
+ path: `${path}.${k}[${i}].owners`,
378
+ });
379
+ }
380
+ }
381
+ });
382
+ // Do not descend into the spawn entry (its nested child definition's owners are relative to that
383
+ // child, not to this parent).
384
+ continue;
385
+ }
386
+ out.push(...lintSpawnOwners(v, app, transition, `${path}.${k}`));
387
+ }
388
+ return out;
389
+ }
390
+ // =============================================================================
290
391
  // Top-level entry point
291
392
  // =============================================================================
292
393
  function appLabel(def) {
@@ -317,6 +418,7 @@ export function lintFiberApp(def) {
317
418
  }
318
419
  if (t.effect !== undefined) {
319
420
  out.push(...lintGuardExpression(t.effect, ctx, `${tPath}.effect`));
421
+ out.push(...lintSpawnOwners(t.effect, app, transition, `${tPath}.effect`));
320
422
  }
321
423
  out.push(...lintTransitionStructure(t, app, transition, tPath));
322
424
  });
@@ -180,6 +180,11 @@ export interface ApplyMorphismMessage {
180
180
  /**
181
181
  * Apply a typed morphism (Transfer / Burn / Compose / ...). asset-model.md §7.
182
182
  * Transfer = `{ kind: "Transfer", recipient }`; Burn (repay) = `{ kind: "Burn" }`.
183
+ *
184
+ * C2 — a `Compose`/`Pool` that folds in a counter-party the signer does NOT own is now rejected on-chain
185
+ * unless a live `AuthorizeCompose` nonce authorizes it: pass that nonce here (`nonce`) and build the commit
186
+ * half with {@link createAuthorizeComposePayload}. A same-holder compose (all parts signer-owned) needs no
187
+ * nonce. `otherAssets` must be duplicate-free and must not include `assetId`.
183
188
  */
184
189
  export declare function createApplyMorphismPayload(params: ApplyMorphismParams): ApplyMorphismMessage;
185
190
  export interface AuthorizeComposeParams {
@@ -198,7 +203,12 @@ export interface AuthorizeComposeMessage {
198
203
  targetSequenceNumber: number;
199
204
  };
200
205
  }
201
- /** Commit half of a two-party compose. asset-model.md §7/§8. */
206
+ /**
207
+ * Commit half of a two-party (cross-holder) compose. asset-model.md §7/§8. As of chain finding C2 this
208
+ * handshake is MANDATORY: a cross-holder `Compose`/`Pool` is rejected unless a matching, unexpired nonce
209
+ * from this call is live for the counter-party. The composing party then echoes `nonce` in its
210
+ * `ApplyMorphism`. A same-holder compose (all parts owned by the signer) does not need this.
211
+ */
202
212
  export declare function createAuthorizeComposePayload(params: AuthorizeComposeParams): AuthorizeComposeMessage;
203
213
  /**
204
214
  * The collateral vault policy: an NFT-like custodial holding (no fungible split) whose
@@ -24,6 +24,8 @@ export type { RequestOptions, TransactionStatus, PendingTransaction, PostTransac
24
24
  export * from './types.js';
25
25
  export * from './ottochain/transaction.js';
26
26
  export { dropNulls } from './ottochain/drop-nulls.js';
27
+ export { lintApplyMorphism, MORPHISM_LINT_CODES } from './ottochain/morphism-lint.js';
28
+ export type { LintViolation, LintSeverity } from './ottochain/morphism-lint.js';
27
29
  export { buildGenesisManifest, GENESIS_MANIFEST_VERSION } from './ottochain/genesis-manifest.js';
28
30
  export type { GenesisManifest, GenesisPackage, GenesisMachineShape, GenesisMessageShape, GenesisFieldShape, GenesisStateMachineDefinition, } from './ottochain/index.js';
29
31
  export * from './generated/index.js';
@@ -31,6 +33,8 @@ export { OttoChainError, NetworkError, ValidationError, SigningError, Transactio
31
33
  export { DagAddressSchema, PrivateKeySchema, PublicKeySchema, KeyPairSchema, SignatureProofSchema, SignedSchema, TransactionReferenceSchema, CurrencyTransactionValueSchema, CurrencyTransactionSchema, TransferParamsSchema, AgentIdentityRegistrationSchema, PlatformLinkSchema, ContractTermsSchema, ProposeContractRequestSchema, AcceptContractRequestSchema, CompleteContractRequestSchema, validate, validatePrivateKey, validatePublicKey, validateAddress, validateKeyPair, safeParse, assert, type ValidatedKeyPair, type ValidatedSignatureProof, type ValidatedCurrencyTransaction, type ValidatedTransferParams, type ValidatedAgentIdentityRegistration, type ValidatedPlatformLink, type ValidatedProposeContractRequest, type ValidatedAcceptContractRequest, type ValidatedCompleteContractRequest, } from './validation.js';
32
34
  export { MetagraphClient as OttoMetagraphClient } from './ottochain/metagraph-client.js';
33
35
  export type { MetagraphClientConfig, Checkpoint, SubscribeOptions, FiberStateCallback, Unsubscribe, } from './ottochain/metagraph-client.js';
36
+ export type { SnapshotNotification, NotificationStats, SnapshotRejection } from './ottochain/webhook-notifications.js';
37
+ export { SNAPSHOT_FINALIZED_EVENT } from './ottochain/webhook-notifications.js';
34
38
  export { toProtoDefinition, defineFiberApp, constrained, unconstrained, immutable, IMMUTABLE_POLICY, projectFiberPolicy, type ProtoStateMachineDefinition, type FiberAppDefinition, type FiberAppMetadata, type FiberPolicy, type FiberPolicyDials, type ImmutablePolicy, type EffectKind, type SpawnOwnerPolicy, type DependencyMode, type TransferPolicy, type DependencyPolicy, type MigrationAuthority, type UpgradePolicy, type VersionRange, type StateDefinition, type StdStateMetadata, type StateCategory, type Transition, } from './schema/fiber-app.js';
35
39
  export { type GuardRule, signerIsParty, signerIsAnyParty, signerInSet, signerIsNotParty, signerHasEntry, assetSignerIs, actorIsSigner, actorInSet, actorHasEntry, signerHasReputation, signerHasRole, signerHasReputationVia, signerHasRoleVia, depInState, actorNotInArray, } from './schema/guards.js';
36
40
  export { addDependency, setDependencyActive, transferAsset, triggers, spawn, emit, scriptCall, toFiber, toWallet, RESERVED_EFFECT_KEYS, } from './schema/effects.js';
@@ -12,8 +12,14 @@ export type { CurrencySnapshotResponse } from './snapshot.js';
12
12
  export { decodeOnChainState, getSnapshotOnChainState, getLatestOnChainState, getLogsForFiber, getEventReceipts, getScriptInvocations, extractOnChainState, } from './snapshot.js';
13
13
  export type { Checkpoint, StateProof, FeeEstimate, TransitionFeeEstimate, ScriptFeeEstimate, VersionInfo, SubscribeRequest, SubscribeResponse, SubscriberList, MetagraphClientConfig, SubscribeOptions, FiberStateCallback, Unsubscribe, } from './metagraph-client.js';
14
14
  export { MetagraphClient } from './metagraph-client.js';
15
+ export type { MptWitnessNode, MptInclusionProof, StateProofVerification } from './state-proof.js';
16
+ export { verifyStateProof, verifyMptInclusion, commitKeyPath } from './state-proof.js';
17
+ export type { SnapshotNotification, NotificationStats, SnapshotRejection } from './webhook-notifications.js';
18
+ export { SNAPSHOT_FINALIZED_EVENT } from './webhook-notifications.js';
15
19
  export { createTransitionPayload, createArchivePayload, createInvokeScriptPayload, signTransaction, addTransactionSignature, getPublicKeyForRegistration, createStateMachinePayload, createScriptPayload, createDataTransactionRequest, createAssetPolicyPayload, createMintAssetPayload, createApplyMorphismPayload, createAuthorizeComposePayload, } from './transaction.js';
16
20
  export type { CreateStateMachineParams, CreateStateMachineMessage, CreateScriptParams, CreateScriptMessage, DataTransactionRequest, TransitionParams, TransitionStateMachineMessage, ArchiveParams, ArchiveStateMachineMessage, InvokeScriptParams, InvokeScriptMessage, } from './transaction.js';
21
+ export { lintApplyMorphism, MORPHISM_LINT_CODES } from './morphism-lint.js';
22
+ export type { LintViolation, LintSeverity } from './morphism-lint.js';
17
23
  export { dropNulls } from './drop-nulls.js';
18
24
  export { buildGenesisManifest, GENESIS_MANIFEST_VERSION } from './genesis-manifest.js';
19
25
  export type { GenesisManifest, GenesisPackage, MachineShape as GenesisMachineShape, MessageShape as GenesisMessageShape, FieldShape as GenesisFieldShape, StateMachineDefinition as GenesisStateMachineDefinition, } from './genesis-manifest.js';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Message-layer advisory lint for an {@link ApplyMorphism} transaction (asset-model.md §7; chain
3
+ * audit finding C2, enforced in `AssetCombiner`).
4
+ *
5
+ * Purpose
6
+ * -------
7
+ * The definition-scoped {@link ../schema/guard-lint.ts | guard-lint} walks fiber-app DEFINITIONS; it
8
+ * never sees an `ApplyMorphism` MESSAGE, so a malformed / no-consent `Compose` slips past it. This
9
+ * linter closes that gap at the transaction-builder layer: hand it the `ApplyMorphism` you are about
10
+ * to sign and it flags the two classes of C2 mistake the chain rejects.
11
+ *
12
+ * The chain rule being mirrored (C2, `Compose`/`Pool` only):
13
+ * - `otherAssetIds` must be free of duplicates and must NOT include the source `assetId`
14
+ * (the old self/duplicate-inflation path — rejected unconditionally, holder-independent).
15
+ * - every counter-party the signer does NOT hold requires a live {@link AuthorizeCompose} `nonce`;
16
+ * a same-holder / all-signer-owned compose needs none.
17
+ *
18
+ * ERRORS are statically certain (the chain rejects regardless of who holds what). WARNINGS are
19
+ * heuristic — the signer's holdings/ownership are unknown at build time, so a missing consent nonce
20
+ * cannot be proven fatal (a same-holder compose is legitimately nonce-less).
21
+ *
22
+ * This is a PURE, non-throwing advisory. It is deliberately NOT wired into
23
+ * {@link createApplyMorphismPayload} (which must stay non-breaking and side-effect-free); call it
24
+ * explicitly before signing, or in your app's own pre-flight checks.
25
+ */
26
+ import type { ApplyMorphism } from './types.js';
27
+ import type { LintViolation } from '../schema/guard-lint.js';
28
+ export type { LintViolation, LintSeverity } from '../schema/guard-lint.js';
29
+ /** Stable rule codes for {@link lintApplyMorphism} — referenced by tests and report tooling. */
30
+ export declare const MORPHISM_LINT_CODES: {
31
+ /** `otherAssetIds` contains a duplicate id (chain rejects — inflation path). */
32
+ readonly DUPLICATE_OTHER_ID: "morphism-duplicate-other-id";
33
+ /** `otherAssetIds` includes the source `assetId` (self-composition — chain rejects). */
34
+ readonly SELF_COMPOSE: "morphism-self-compose";
35
+ /** `otherAssetIds` is non-empty but `kind` ignores it (not Compose/Pool — likely a mistake). */
36
+ readonly OTHER_IDS_IGNORED: "morphism-other-ids-ignored";
37
+ /** Compose/Pool with counter-parties and no consent `nonce` (rejected IF any part is not signer-owned). */
38
+ readonly COMPOSE_NO_NONCE: "morphism-compose-no-nonce";
39
+ };
40
+ /**
41
+ * Lint a single {@link ApplyMorphism} message. Pure and non-throwing. Returns every finding
42
+ * (`error` + `warn`); callers decide the policy (e.g. block on any `error`, surface `warn`s).
43
+ *
44
+ * ERRORS (statically certain — the chain rejects unconditionally, holder-independent):
45
+ * - {@link MORPHISM_LINT_CODES.DUPLICATE_OTHER_ID} — `otherAssetIds` contains a duplicate id.
46
+ * - {@link MORPHISM_LINT_CODES.SELF_COMPOSE} — `otherAssetIds` includes the source `assetId`.
47
+ * - {@link MORPHISM_LINT_CODES.OTHER_IDS_IGNORED} — `otherAssetIds` is non-empty but `kind` is not
48
+ * `COMPOSE`/`POOL` (the field is ignored for other kinds — a likely authoring mistake).
49
+ *
50
+ * WARNINGS (heuristic — holder/ownership unknown at build time):
51
+ * - {@link MORPHISM_LINT_CODES.COMPOSE_NO_NONCE} — a `COMPOSE`/`POOL` with a non-empty
52
+ * `otherAssetIds` and NO `nonce`. IF any counter-party is not signer-owned the chain rejects it
53
+ * (C2); attach the reveal half of an {@link AuthorizeCompose} handshake as `nonce`. A same-holder
54
+ * compose (all parts signer-owned) is legitimately nonce-less — hence a warning, not an error.
55
+ */
56
+ export declare function lintApplyMorphism(msg: ApplyMorphism): LintViolation[];
@@ -0,0 +1,51 @@
1
+ import type { StateProof } from './metagraph-client.js';
2
+ /** One commitment node of a Merkle-Patricia inclusion witness (wire form, leaf-first). */
3
+ export interface MptWitnessNode {
4
+ type: 'Leaf' | 'Branch' | 'Extension';
5
+ contents: {
6
+ /** Leaf: the path nibbles left below the last branch. */
7
+ remaining?: string;
8
+ /** Leaf: sha256(JCS(dropNulls(value))) of the committed record. */
9
+ dataDigest?: string;
10
+ /** Branch: nibble → child node digest. */
11
+ pathsDigest?: Record<string, string>;
12
+ /** Extension: the shared nibble run. */
13
+ shared?: string;
14
+ /** Extension: digest of the child branch. */
15
+ childDigest?: string;
16
+ };
17
+ }
18
+ /** The `proof` field of a {@link StateProof}, typed. */
19
+ export interface MptInclusionProof {
20
+ /** Trie path = hex(utf8(committed key)). */
21
+ path: string;
22
+ witness: MptWitnessNode[];
23
+ }
24
+ /** Verification outcome: `ok`, or the first failed binding with a human-readable reason. */
25
+ export type StateProofVerification = {
26
+ ok: true;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /** The MPT trie path of a committed key — `hex(utf8(key))` (metakit `CommitKey.toHex`). */
32
+ export declare function commitKeyPath(key: string): string;
33
+ /**
34
+ * Verify a Merkle-Patricia inclusion proof: `record` is committed at `key` under `mptRoot`.
35
+ *
36
+ * Pure and side-effect free; never throws on malformed input (returns `{ ok: false }`).
37
+ */
38
+ export declare function verifyMptInclusion(mptRoot: string, key: string, record: unknown, proof: MptInclusionProof): StateProofVerification;
39
+ /**
40
+ * Verify a whole {@link StateProof} response for a committed key.
41
+ *
42
+ * Checks the trie inclusion of `record` at `key` under the response's `mptRoot` (see
43
+ * {@link verifyMptInclusion}). The caller supplies the key it EXPECTS (`fiber/<id>`,
44
+ * `script/<id>`, `asset/<id>`, …) so a proof for some other record cannot be substituted;
45
+ * the response's own `key` field must agree.
46
+ *
47
+ * Trust anchor: `committedRoot` (= `sha256(mptRoot ++ catalogRoot)`) rides in the
48
+ * consensus-signed snapshot as `calculatedStateProof`; binding `mptRoot` to a snapshot is
49
+ * the caller's (or a snapshot-following light client's) responsibility.
50
+ */
51
+ export declare function verifyStateProof(resp: StateProof, expectedKey: string): StateProofVerification;
@@ -194,7 +194,13 @@ export declare function createAssetPolicyPayload(params: CreateAssetPolicy): {
194
194
  export declare function createMintAssetPayload(params: MintAsset): {
195
195
  MintAsset: MintAsset;
196
196
  };
197
- /** Wrap an {@link ApplyMorphism} (apply a typed morphism to an asset instance). */
197
+ /**
198
+ * Wrap an {@link ApplyMorphism} (apply a typed morphism to an asset instance).
199
+ *
200
+ * @see lintApplyMorphism (`./morphism-lint.ts`) — pure, non-throwing advisory that flags the
201
+ * Compose/Pool mistakes the chain rejects (duplicate / self-referential `otherAssetIds`; a
202
+ * cross-holder compose with no consent `nonce`). Run it on `params` before signing.
203
+ */
198
204
  export declare function createApplyMorphismPayload(params: ApplyMorphism): {
199
205
  ApplyMorphism: ApplyMorphism;
200
206
  };