@hyperscale0/udl 1.0.0 → 1.0.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CHANGELOG.md +5 -216
  2. package/LICENSE.md +356 -0
  3. package/NOTICE.md +8 -0
  4. package/README.md +14 -106
  5. package/dist/canonical.d.ts.map +1 -1
  6. package/dist/canonical.js +1 -29
  7. package/dist/canonical.js.map +1 -1
  8. package/dist/diagnostics.d.ts +13 -165
  9. package/dist/diagnostics.d.ts.map +1 -1
  10. package/dist/diagnostics.js +33 -157
  11. package/dist/diagnostics.js.map +1 -1
  12. package/dist/field-value.d.ts +5 -0
  13. package/dist/field-value.d.ts.map +1 -0
  14. package/dist/field-value.js +85 -0
  15. package/dist/field-value.js.map +1 -0
  16. package/dist/finance.d.ts +10 -53
  17. package/dist/finance.d.ts.map +1 -1
  18. package/dist/finance.js +252 -536
  19. package/dist/finance.js.map +1 -1
  20. package/dist/index.d.ts +9 -13
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +9 -9
  23. package/dist/index.js.map +1 -1
  24. package/dist/json.d.ts +2 -0
  25. package/dist/json.d.ts.map +1 -0
  26. package/dist/json.js +30 -0
  27. package/dist/json.js.map +1 -0
  28. package/dist/limits.d.ts +3 -28
  29. package/dist/limits.d.ts.map +1 -1
  30. package/dist/limits.js +5 -31
  31. package/dist/limits.js.map +1 -1
  32. package/dist/object-contract.d.ts +140 -0
  33. package/dist/object-contract.d.ts.map +1 -0
  34. package/dist/object-contract.js +314 -0
  35. package/dist/object-contract.js.map +1 -0
  36. package/dist/object-semantics-schema.d.ts +5710 -0
  37. package/dist/object-semantics-schema.d.ts.map +1 -0
  38. package/dist/object-semantics-schema.js +119 -0
  39. package/dist/object-semantics-schema.js.map +1 -0
  40. package/dist/object-semantics.d.ts +6 -0
  41. package/dist/object-semantics.d.ts.map +1 -0
  42. package/dist/object-semantics.js +279 -0
  43. package/dist/object-semantics.js.map +1 -0
  44. package/dist/object-structures.d.ts +4 -0
  45. package/dist/object-structures.d.ts.map +1 -0
  46. package/dist/object-structures.js +134 -0
  47. package/dist/object-structures.js.map +1 -0
  48. package/dist/reporting-validation.d.ts +8 -0
  49. package/dist/reporting-validation.d.ts.map +1 -0
  50. package/dist/reporting-validation.js +252 -0
  51. package/dist/reporting-validation.js.map +1 -0
  52. package/dist/reporting.d.ts +477 -0
  53. package/dist/reporting.d.ts.map +1 -0
  54. package/dist/reporting.js +169 -0
  55. package/dist/reporting.js.map +1 -0
  56. package/dist/schema.d.ts +5572 -2710
  57. package/dist/schema.d.ts.map +1 -1
  58. package/dist/schema.js +444 -1121
  59. package/dist/schema.js.map +1 -1
  60. package/dist/validation.d.ts +10 -73
  61. package/dist/validation.d.ts.map +1 -1
  62. package/dist/validation.js +1109 -2569
  63. package/dist/validation.js.map +1 -1
  64. package/docs/assets/brand/manifest.json +30 -0
  65. package/docs/assets/brand/udl-horizontal-white.svg +1 -0
  66. package/docs/assets/brand/udl-horizontal.svg +1 -0
  67. package/docs/assets/brand/udl-stacked-white.svg +1 -0
  68. package/docs/assets/brand/udl-stacked.svg +1 -0
  69. package/docs/assets/udl.svg +11 -0
  70. package/package.json +13 -22
  71. package/spec/README.md +300 -172
  72. package/spec/cars.udl.json +249 -0
  73. package/spec/udl.schema.json +3066 -2242
  74. package/src/canonical.ts +1 -35
  75. package/src/diagnostics.ts +76 -209
  76. package/src/field-value.ts +87 -0
  77. package/src/finance.ts +327 -832
  78. package/src/index.ts +18 -95
  79. package/src/json.ts +34 -0
  80. package/src/limits.ts +5 -31
  81. package/src/object-contract.ts +559 -0
  82. package/src/object-semantics-schema.ts +148 -0
  83. package/src/object-semantics.ts +378 -0
  84. package/src/object-structures.ts +255 -0
  85. package/src/reporting-validation.ts +383 -0
  86. package/src/reporting.ts +178 -0
  87. package/src/schema.ts +510 -1277
  88. package/src/validation.ts +1630 -4545
  89. package/LICENSE +0 -661
  90. package/LICENSING.md +0 -51
  91. package/TRADEMARKS.md +0 -35
  92. package/conformance/README.md +0 -82
  93. package/conformance/evolution/action-contract.expected.json +0 -10
  94. package/conformance/evolution/action-contract.live.udl +0 -44
  95. package/conformance/evolution/action-contract.next.udl +0 -45
  96. package/conformance/evolution/product-identity.expected.json +0 -10
  97. package/conformance/evolution/product-identity.live.udl +0 -55
  98. package/conformance/evolution/product-identity.next.udl +0 -55
  99. package/conformance/evolution/version-required.expected.json +0 -10
  100. package/conformance/evolution/version-required.live.udl +0 -55
  101. package/conformance/evolution/version-required.next.udl +0 -58
  102. package/conformance/invalid/action-without-transition.expected.json +0 -10
  103. package/conformance/invalid/action-without-transition.udl +0 -60
  104. package/conformance/invalid/agent-description-too-long.expected.json +0 -10
  105. package/conformance/invalid/agent-description-too-long.udl +0 -57
  106. package/conformance/invalid/blank-title.expected.json +0 -10
  107. package/conformance/invalid/blank-title.udl +0 -54
  108. package/conformance/invalid/composition-dial-duplicate-key.expected.json +0 -10
  109. package/conformance/invalid/composition-dial-duplicate-key.udl +0 -73
  110. package/conformance/invalid/depth-budget.expected.json +0 -10
  111. package/conformance/invalid/depth-budget.udl +0 -49
  112. package/conformance/invalid/duplicate-subject.expected.json +0 -10
  113. package/conformance/invalid/duplicate-subject.udl +0 -230
  114. package/conformance/invalid/forged-effects.expected.json +0 -10
  115. package/conformance/invalid/forged-effects.udl +0 -76
  116. package/conformance/invalid/format-version.expected.json +0 -10
  117. package/conformance/invalid/format-version.udl +0 -54
  118. package/conformance/invalid/instrument-id-not-snake-case.expected.json +0 -10
  119. package/conformance/invalid/instrument-id-not-snake-case.udl +0 -54
  120. package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +0 -10
  121. package/conformance/invalid/invalid-aggregate-gate-shape.udl +0 -1819
  122. package/conformance/invalid/invalid-check-duration.expected.json +0 -10
  123. package/conformance/invalid/invalid-check-duration.udl +0 -219
  124. package/conformance/invalid/invalid-dial-anchor.expected.json +0 -10
  125. package/conformance/invalid/invalid-dial-anchor.udl +0 -219
  126. package/conformance/invalid/invalid-exception-parent-ref.expected.json +0 -10
  127. package/conformance/invalid/invalid-exception-parent-ref.udl +0 -2487
  128. package/conformance/invalid/invalid-exposure-shape.expected.json +0 -10
  129. package/conformance/invalid/invalid-exposure-shape.udl +0 -1819
  130. package/conformance/invalid/invalid-remainder.expected.json +0 -10
  131. package/conformance/invalid/invalid-remainder.udl +0 -220
  132. package/conformance/invalid/invalid-schema-keyword.expected.json +0 -10
  133. package/conformance/invalid/invalid-schema-keyword.udl +0 -220
  134. package/conformance/invalid/invalid-utf8.expected.json +0 -10
  135. package/conformance/invalid/invalid-utf8.udl +0 -1
  136. package/conformance/invalid/malformed-json.expected.json +0 -10
  137. package/conformance/invalid/malformed-json.udl +0 -1
  138. package/conformance/invalid/missing-create-action.expected.json +0 -10
  139. package/conformance/invalid/missing-create-action.udl +0 -48
  140. package/conformance/invalid/missing-exception-amount-field.expected.json +0 -10
  141. package/conformance/invalid/missing-exception-amount-field.udl +0 -2487
  142. package/conformance/invalid/missing-exception-contract.expected.json +0 -14
  143. package/conformance/invalid/missing-exception-contract.udl +0 -2523
  144. package/conformance/invalid/missing-exception-reason-field.expected.json +0 -10
  145. package/conformance/invalid/missing-exception-reason-field.udl +0 -2487
  146. package/conformance/invalid/not-an-object.expected.json +0 -10
  147. package/conformance/invalid/not-an-object.udl +0 -1
  148. package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +0 -10
  149. package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +0 -259
  150. package/conformance/invalid/quote-freeze-set-incomplete.expected.json +0 -10
  151. package/conformance/invalid/quote-freeze-set-incomplete.udl +0 -261
  152. package/conformance/invalid/quote-named-reference-gate.expected.json +0 -10
  153. package/conformance/invalid/quote-named-reference-gate.udl +0 -50
  154. package/conformance/invalid/reconcile-named-reference-gate.expected.json +0 -10
  155. package/conformance/invalid/reconcile-named-reference-gate.udl +0 -50
  156. package/conformance/invalid/unknown-key.expected.json +0 -10
  157. package/conformance/invalid/unknown-key.udl +0 -55
  158. package/conformance/invalid/unknown-reference-gate-field.expected.json +0 -10
  159. package/conformance/invalid/unknown-reference-gate-field.udl +0 -1819
  160. package/conformance/invalid/unknown-required-field.expected.json +0 -10
  161. package/conformance/invalid/unknown-required-field.udl +0 -220
  162. package/conformance/invalid/unreachable-state.expected.json +0 -10
  163. package/conformance/invalid/unreachable-state.udl +0 -55
  164. package/conformance/invalid/wrong-exception-amount-field.expected.json +0 -10
  165. package/conformance/invalid/wrong-exception-amount-field.udl +0 -2487
  166. package/conformance/invalid/wrong-exception-reason-field.expected.json +0 -10
  167. package/conformance/invalid/wrong-exception-reason-field.udl +0 -2487
  168. package/conformance/valid/agent-description.expected.json +0 -6
  169. package/conformance/valid/agent-description.udl +0 -66
  170. package/conformance/valid/cards.expected.json +0 -6
  171. package/conformance/valid/cards.udl +0 -1579
  172. package/conformance/valid/commerce-escrow.expected.json +0 -6
  173. package/conformance/valid/commerce-escrow.udl +0 -1512
  174. package/conformance/valid/compiled-crowdfunding.expected.json +0 -6
  175. package/conformance/valid/compiled-crowdfunding.udl +0 -1843
  176. package/conformance/valid/compiled-watch-club.expected.json +0 -6
  177. package/conformance/valid/compiled-watch-club.udl +0 -2486
  178. package/conformance/valid/complete-contract.expected.json +0 -6
  179. package/conformance/valid/complete-contract.udl +0 -218
  180. package/conformance/valid/effect-signatures.expected.json +0 -6
  181. package/conformance/valid/effect-signatures.udl +0 -75
  182. package/conformance/valid/hand-edited.expected.json +0 -6
  183. package/conformance/valid/hand-edited.udl +0 -1
  184. package/conformance/valid/insured-car-marketplace.expected.json +0 -6
  185. package/conformance/valid/insured-car-marketplace.udl +0 -1050
  186. package/conformance/valid/insured-travel.expected.json +0 -6
  187. package/conformance/valid/insured-travel.udl +0 -3469
  188. package/conformance/valid/minimal.expected.json +0 -6
  189. package/conformance/valid/minimal.udl +0 -54
  190. package/conformance/valid/protection.expected.json +0 -6
  191. package/conformance/valid/protection.udl +0 -1551
  192. package/conformance/valid/string-escaping.expected.json +0 -6
  193. package/conformance/valid/string-escaping.udl +0 -54
  194. package/dist/check-profiles.d.ts +0 -57
  195. package/dist/check-profiles.d.ts.map +0 -1
  196. package/dist/check-profiles.js +0 -62
  197. package/dist/check-profiles.js.map +0 -1
  198. package/dist/cli.d.ts +0 -3
  199. package/dist/cli.d.ts.map +0 -1
  200. package/dist/cli.js +0 -184
  201. package/dist/cli.js.map +0 -1
  202. package/dist/effects.d.ts +0 -41
  203. package/dist/effects.d.ts.map +0 -1
  204. package/dist/effects.js +0 -110
  205. package/dist/effects.js.map +0 -1
  206. package/dist/evolution.d.ts +0 -120
  207. package/dist/evolution.d.ts.map +0 -1
  208. package/dist/evolution.js +0 -567
  209. package/dist/evolution.js.map +0 -1
  210. package/dist/parser.d.ts +0 -4
  211. package/dist/parser.d.ts.map +0 -1
  212. package/dist/parser.js +0 -44
  213. package/dist/parser.js.map +0 -1
  214. package/docs/README.md +0 -14
  215. package/docs/guide/01-a-document.md +0 -37
  216. package/docs/guide/02-money-steps.md +0 -23
  217. package/docs/guide/03-laws.md +0 -18
  218. package/docs/guide/04-fees-and-remainder.md +0 -15
  219. package/docs/guide/05-checks-updates-dials.md +0 -7
  220. package/docs/guide/06-effects.md +0 -11
  221. package/docs/guide/07-evolution.md +0 -11
  222. package/docs/guide/08-implementing.md +0 -13
  223. package/docs/llms-full.txt +0 -1241
  224. package/docs/llms.txt +0 -14
  225. package/docs/reference/canonical.md +0 -16
  226. package/docs/reference/clauses.md +0 -986
  227. package/docs/reference/cli.md +0 -22
  228. package/docs/reference/diagnostics.md +0 -32
  229. package/skills/udl/SKILL.md +0 -28
  230. package/src/check-profiles.ts +0 -80
  231. package/src/cli.ts +0 -196
  232. package/src/effects.ts +0 -163
  233. package/src/evolution.ts +0 -965
  234. package/src/parser.ts +0 -58
package/src/finance.ts CHANGED
@@ -1,870 +1,365 @@
1
- import type { UdlBinding } from "./schema.js";
2
- import { UDL_LIMITS } from "./limits.js";
3
- import type { UdlIssueCode } from "./diagnostics.js";
1
+ import type {
2
+ UdlAction,
3
+ UdlDocument,
4
+ UdlInstrument,
5
+ UdlValue,
6
+ } from "./schema.js";
4
7
 
5
8
  export interface FinanceIssue {
6
- readonly code: UdlIssueCode;
7
- readonly message: string;
8
- readonly path: readonly PropertyKey[];
9
- }
10
-
11
- export interface FinanceOptions {
12
- readonly penaltyMayBeNonzero?: boolean;
13
- }
14
-
15
- interface FinancialStep {
16
- readonly operation: string;
17
- readonly bind: Readonly<Record<string, UdlBinding>>;
18
- readonly capture?: Readonly<Record<string, string>> | undefined;
19
- }
20
-
21
- interface FinancialMove extends FinancialStep {
22
- readonly key: string;
23
- }
24
-
25
- interface FinancialInstrument {
26
- readonly lifecycle: {
27
- readonly initial: string;
28
- readonly states: readonly string[];
29
- readonly transitions: Readonly<
30
- Record<string, { readonly from: readonly string[]; readonly to: string }>
31
- >;
9
+ path: string;
10
+ message: string;
11
+ stranded?: {
12
+ state: string;
13
+ accounts: string[];
14
+ actions: string[];
15
+ paths: Record<string, string[]>;
32
16
  };
33
- readonly parties?:
34
- | {
35
- readonly beneficiary?: string | undefined;
36
- readonly payer?: string | undefined;
37
- readonly subjectHolder?: string | undefined;
38
- }
39
- | undefined;
40
- readonly actions: Readonly<
41
- Record<
42
- string,
43
- {
44
- readonly commit?: string | undefined;
45
- readonly moves?: readonly FinancialMove[];
46
- readonly quote?:
47
- | {
48
- readonly baseField: string;
49
- readonly chargeRef: string;
50
- readonly charges: readonly { readonly bps: number }[];
51
- readonly netRef: string;
52
- }
53
- | undefined;
54
- readonly steps: readonly FinancialStep[];
55
- }
56
- >
57
- >;
58
- }
59
-
60
- /**
61
- * One quote-commit pair, flattened for the typestate. The quoting action prices
62
- * `baseField` into the charge and the net; the committing action spends the net
63
- * out of the account that funded the base.
64
- */
65
- interface QuoteFacts {
66
- readonly baseField: string;
67
- readonly chargeRef: string;
68
- readonly chargeCanBeNonzero: boolean;
69
- readonly commit: string;
70
- readonly netRef: string;
71
- readonly quoting: string;
72
- }
73
-
74
- type Balance =
75
- | { readonly kind: "empty" }
76
- | { readonly kind: "funded"; readonly amounts: readonly string[] }
77
- | { readonly kind: "penalty" }
78
- | { readonly kind: "unknown" };
79
-
80
- interface AccountState {
81
- readonly balance: Balance;
82
- readonly holds: Readonly<Record<string, Balance>>;
83
- }
84
-
85
- interface Reservation {
86
- readonly amount: string;
87
- readonly destination?: string;
88
- readonly source?: string;
89
- }
90
-
91
- interface Effect {
92
- readonly amount: string;
93
- /** The quoted base a refund effect returns; absent on every other kind. */
94
- readonly refundable?: string;
95
- readonly kind:
96
- | "credit"
97
- | "debit"
98
- | "dynamic_debit"
99
- | "incoming_reserve"
100
- | "outgoing_reserve"
101
- | "penalty_debit"
102
- | "post"
103
- | "refund"
104
- | "void";
105
- readonly path: readonly PropertyKey[];
106
- readonly reservation?: string;
107
17
  }
108
-
109
- const EMPTY: Balance = { kind: "empty" };
110
-
111
- const UNKNOWN: Balance = { kind: "unknown" };
112
-
113
- // A funded balance is the exact set of amount identities currently held.
114
- // Distinct identities stack (a partitioned funding); re-crediting an identity
115
- // already present degrades to unknown, which keeps the state space finite.
116
- function funded(amounts: readonly string[]): Balance {
117
- if (amounts.length === 0) return EMPTY;
118
- return { kind: "funded", amounts: [...amounts].sort() };
18
+ type Sum = Map<string, bigint>;
19
+ type Balance = Sum | null;
20
+ interface Hold {
21
+ from: string;
22
+ to: string;
23
+ amount: Balance;
119
24
  }
120
-
121
- function withoutPiece(
122
- amounts: readonly string[],
123
- amount: string,
124
- ): readonly string[] {
125
- const index = amounts.indexOf(amount);
126
- return amounts.filter((_, position) => position !== index);
25
+ interface State {
26
+ status: string;
27
+ actions: string[];
28
+ paths: Map<string, string[]>;
29
+ balances: Map<string, Balance>;
30
+ holds: Map<string, Hold>;
127
31
  }
128
-
129
- /**
130
- * One financial typestate owner for canonical UDL and static instrument contracts.
131
- * It proves a tracked escrow account holds an exact set of funded amounts,
132
- * models held transfers through reserve/post/void, consumes every direct
133
- * debit piece by piece, and makes the quoted refund's sole remainder an
134
- * exactly-once penalty payout.
135
- */
136
- export function analyzeInstrumentFinance(
137
- instrument: FinancialInstrument,
138
- options: FinanceOptions = {},
139
- ): readonly FinanceIssue[] {
140
- const issues: FinanceIssue[] = [];
141
- const seenIssues = new Set<string>();
142
- const add = (
143
- path: readonly PropertyKey[],
144
- message: string,
145
- code: UdlIssueCode = "UDL4001",
146
- ): void => {
147
- const key = `${path.join(".")}\0${message}`;
148
- if (seenIssues.has(key)) return;
149
- seenIssues.add(key);
150
- issues.push({ code, message, path });
151
- };
152
-
153
- const admissionProblem = financeAdmissionProblem(instrument);
154
- if (admissionProblem) {
155
- add(["lifecycle"], admissionProblem, "UDL1004");
156
- return issues;
32
+ const add = (a: Balance, b: Balance, sign = 1n): Balance => {
33
+ if (a === null || b === null) return null;
34
+ const result = new Map(a);
35
+ for (const [key, value] of b) {
36
+ const next = (result.get(key) ?? 0n) + value * sign;
37
+ if (next) result.set(key, next);
38
+ else result.delete(key);
157
39
  }
40
+ return result;
41
+ };
42
+ const show = (sum: Balance): string =>
43
+ sum === null
44
+ ? "unknown"
45
+ : JSON.stringify(
46
+ [...sum]
47
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
48
+ .map(([key, value]) => [key, String(value)]),
49
+ );
50
+ const same = (a: Balance, b: Balance) => show(a) === show(b);
158
51
 
159
- const trackedAccounts = productEscrowAccounts(instrument);
160
- const quotes = quoteFacts(instrument, options);
161
- const refundSources = new Map<string, string>();
162
- for (const quote of quotes) {
163
- const commitTransfer = instrument.actions[quote.commit]?.moves?.filter(
164
- (move) => move.operation === "internal_transfer.create",
165
- )[0];
166
- const source = canonicalAccount(
167
- instrument,
168
- commitTransfer?.bind.sourceAccountId,
169
- );
170
- if (!source) continue;
171
- refundSources.set(quote.commit, source);
172
- trackedAccounts.add(source);
52
+ /** Join loop balances conservatively; a balance equality proves an unknown balance empty. */
53
+ export function analyzeInstrumentFinance(
54
+ instrument: UdlInstrument,
55
+ document?: UdlDocument,
56
+ ): FinanceIssue[] {
57
+ const owned = new Set(
58
+ instrument.fields
59
+ .filter((f) => f.type === "account" && f.owner === "self")
60
+ .map((f) => `self.${f.name}`),
61
+ );
62
+ const shared = new Set<string>();
63
+ for (const owner of document?.instruments ?? []) {
64
+ if (owner.id === instrument.id) continue;
65
+ for (const action of Object.values(owner.actions))
66
+ for (const move of action.moves)
67
+ if ("amount" in move)
68
+ for (const endpoint of [move.from, move.to]) {
69
+ const parts = endpoint.split(".");
70
+ if (parts.shift() !== "self") continue;
71
+ let targets = [owner];
72
+ while (parts.length > 1) {
73
+ const key = parts.shift();
74
+ targets = targets.flatMap((target) => {
75
+ const reference = target.fields.find(
76
+ (field) => field.name === key,
77
+ );
78
+ if (reference?.type !== "ref") return [];
79
+ const ids =
80
+ typeof reference.target === "string"
81
+ ? [reference.target]
82
+ : reference.target;
83
+ return (
84
+ document?.instruments.filter((candidate) =>
85
+ ids.includes(candidate.id),
86
+ ) ?? []
87
+ );
88
+ });
89
+ }
90
+ if (
91
+ parts.length === 1 &&
92
+ targets.some((target) => target.id === instrument.id) &&
93
+ owned.has(`self.${parts[0]}`)
94
+ )
95
+ shared.add(`self.${parts[0]}`);
96
+ }
173
97
  }
174
- const refundSourceAccounts = new Set(refundSources.values());
175
- if (trackedAccounts.size === 0) return [];
176
- if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
177
- add(
178
- ["actions"],
179
- `financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`,
180
- "UDL1004",
181
- );
182
- return issues;
98
+ const problems: FinanceIssue[] = [];
99
+ const report = (message: string) =>
100
+ problems.push({ path: ".actions", message });
101
+ const equations = new Map<string, Sum>();
102
+ for (const field of instrument.fields)
103
+ if (field.type === "money" && field.value !== undefined)
104
+ equations.set(
105
+ `self.${field.name}`,
106
+ new Map([["#", BigInt(field.value)]]),
107
+ );
108
+ for (const calc of instrument.calculate) {
109
+ if (calc.op === "sum" || calc.op === "subtract") {
110
+ const terms =
111
+ calc.op === "sum"
112
+ ? calc.values.map((v) => [v, 1n] as const)
113
+ : [
114
+ [calc.base, 1n] as const,
115
+ ...calc.subtract.map((v) => [v, -1n] as const),
116
+ ];
117
+ let result: Balance = new Map();
118
+ for (const [value, sign] of terms)
119
+ result = add(result, raw(value, "create"), sign);
120
+ equations.set(`self.${calc.target}`, result!);
121
+ }
183
122
  }
184
-
185
- const reservations = reservationsByKey(instrument);
186
- for (const quote of quotes) {
187
- validateChargePayout(
188
- instrument,
189
- quote,
190
- refundSources.get(quote.commit),
191
- add,
192
- );
123
+ function raw(value: UdlValue, invocation: string): Sum {
124
+ return "field" in value
125
+ ? new Map([
126
+ [
127
+ value.field.startsWith("input.")
128
+ ? `${invocation}:${value.field}`
129
+ : value.field,
130
+ 1n,
131
+ ],
132
+ ])
133
+ : new Map([["#", BigInt(String(value.literal))]]);
193
134
  }
194
-
195
- let pathVariants = 0;
196
- let work = 0;
197
- for (const account of trackedAccounts) {
198
- const effects = effectsByAction(instrument, account, reservations, quotes);
199
- const createEffects = effects.get("create") ?? [];
200
- work += 1 + createEffects.length;
201
- if (work > UDL_LIMITS.financeWork) {
202
- add(
203
- ["lifecycle"],
204
- `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
205
- "UDL1004",
135
+ function expand(sum: Sum, seen = new Set<string>()): Sum {
136
+ let result: Balance = new Map();
137
+ for (const [key, coefficient] of sum) {
138
+ const equation = equations.get(key);
139
+ result = add(
140
+ result,
141
+ equation && !seen.has(key)
142
+ ? expand(equation, new Set([...seen, key]))
143
+ : new Map([[key, 1n]]),
144
+ coefficient,
206
145
  );
207
- return issues;
208
146
  }
209
- const initial = applyEffects(
210
- account,
211
- "create",
212
- { balance: EMPTY, holds: {} },
213
- createEffects,
214
- reservations,
215
- refundSourceAccounts.has(account),
216
- add,
147
+ return result!;
148
+ }
149
+ let invocation = 0;
150
+ const apply = (state: State, action: UdlAction, name: string): void => {
151
+ const scope = `${name}#${invocation++}`;
152
+ const calculated = new Map<string, Balance>();
153
+ const local = new Map(
154
+ (action.calculate ?? []).map((c) => [`self.${c.target}`, c]),
217
155
  );
218
- const states = new Map<string, Map<string, AccountState>>([
219
- [instrument.lifecycle.initial, new Map([[stateKey(initial), initial]])],
220
- ]);
221
- pathVariants += 1;
222
- const pending = [instrument.lifecycle.initial];
223
- while (pending.length > 0) {
224
- const from = pending.shift() as string;
225
- const sourceStates = states.get(from);
226
- if (!sourceStates) continue;
227
- for (const [action, transition] of Object.entries(
228
- instrument.lifecycle.transitions,
229
- )) {
230
- if (!transition.from.includes(from)) continue;
231
- const targetStates = states.get(transition.to) ?? new Map();
232
- states.set(transition.to, targetStates);
233
- let grew = false;
234
- for (const sourceState of sourceStates.values()) {
235
- const transitionEffects = effects.get(action) ?? [];
236
- work += 1 + transitionEffects.length;
237
- if (work > UDL_LIMITS.financeWork) {
238
- add(
239
- ["lifecycle"],
240
- `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
241
- "UDL1004",
156
+ const amount = (value: UdlValue, seen = new Set<string>()): Balance => {
157
+ if ("field" in value) {
158
+ if (calculated.has(value.field)) return calculated.get(value.field)!;
159
+ if (
160
+ value.field.endsWith(".balance") &&
161
+ owned.has(value.field.slice(0, -8))
162
+ )
163
+ return state.balances.get(value.field.slice(0, -8)) ?? null;
164
+ const calculation = local.get(value.field);
165
+ if (calculation && !seen.has(value.field)) {
166
+ const next = new Set([...seen, value.field]);
167
+ if (calculation.op === "sum")
168
+ return calculation.values.reduce<Balance>(
169
+ (sum, value) => add(sum, amount(value, next)),
170
+ new Map(),
242
171
  );
243
- return issues;
244
- }
245
- const targetState = applyEffects(
246
- account,
247
- action,
248
- sourceState,
249
- transitionEffects,
250
- reservations,
251
- refundSourceAccounts.has(account),
252
- add,
253
- );
254
- const key = stateKey(targetState);
255
- if (targetStates.has(key)) continue;
256
- targetStates.set(key, targetState);
257
- pathVariants += 1;
258
- if (pathVariants > UDL_LIMITS.financePathVariants) {
259
- add(
260
- ["lifecycle"],
261
- `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`,
262
- "UDL1004",
172
+ if (calculation.op === "subtract")
173
+ return calculation.subtract.reduce<Balance>(
174
+ (sum, value) => add(sum, amount(value, next), -1n),
175
+ amount(calculation.base, next),
263
176
  );
264
- return issues;
177
+ return new Map([[`${scope}:${value.field}`, 1n]]);
178
+ }
179
+ }
180
+ return expand(raw(value, scope));
181
+ };
182
+ for (const requirement of action.requires)
183
+ if (requirement.kind === "compare" && requirement.operator === "==") {
184
+ for (const [left, right] of [
185
+ [requirement.left, requirement.right],
186
+ [requirement.right, requirement.left],
187
+ ] as const) {
188
+ if ("field" in left && left.field.endsWith(".balance")) {
189
+ const account = left.field.slice(0, -8);
190
+ if (owned.has(account)) state.balances.set(account, amount(right));
265
191
  }
266
- grew = true;
267
192
  }
268
- if (grew && !pending.includes(transition.to))
269
- pending.push(transition.to);
270
193
  }
271
- }
272
- const nonterminalStates = new Set(
273
- Object.values(instrument.lifecycle.transitions).flatMap(
274
- (transition) => transition.from,
275
- ),
276
- );
277
- for (const [state, variants] of states) {
278
- if (nonterminalStates.has(state)) continue;
279
- for (const variant of variants.values()) {
280
- if (
281
- variant.balance.kind === "empty" &&
282
- Object.keys(variant.holds).length === 0
283
- ) {
194
+ for (const key of local.keys()) calculated.set(key, amount({ field: key }));
195
+ const debit = (account: string, value: Balance) => {
196
+ if (owned.has(account))
197
+ state.balances.set(
198
+ account,
199
+ add(
200
+ state.balances.has(account)
201
+ ? state.balances.get(account)!
202
+ : new Map(),
203
+ value,
204
+ -1n,
205
+ ),
206
+ );
207
+ };
208
+ const credit = (account: string, value: Balance) => {
209
+ if (owned.has(account))
210
+ state.balances.set(
211
+ account,
212
+ add(
213
+ state.balances.has(account)
214
+ ? state.balances.get(account)!
215
+ : new Map(),
216
+ value,
217
+ ),
218
+ );
219
+ };
220
+ for (const move of action.moves) {
221
+ if ("amount" in move) {
222
+ const value = amount(move.amount);
223
+ debit(move.from, value);
224
+ if (move.operation === "internal_transfer.reserve") {
225
+ const key = `self.${move.capture}`;
226
+ if (state.holds.has(key))
227
+ report(`${move.capture} overwrites a live reservation`);
228
+ state.holds.set(key, { from: move.from, to: move.to, amount: value });
229
+ } else credit(move.to, value);
230
+ } else {
231
+ const hold = state.holds.get(move.transfer);
232
+ if (!hold) {
233
+ report(`${move.key} consumes a missing reservation ${move.transfer}`);
284
234
  continue;
285
235
  }
286
- add(
287
- ["lifecycle", "states", instrument.lifecycle.states.indexOf(state)],
288
- `terminal state ${state} can strand value in ${formatAccount(account)}`,
236
+ credit(
237
+ move.operation === "internal_transfer.post" ? hold.to : hold.from,
238
+ hold.amount,
289
239
  );
240
+ state.holds.delete(move.transfer);
290
241
  }
291
242
  }
292
- }
293
-
294
- return issues;
295
- }
296
-
297
- export function financeAdmissionProblem(
298
- instrument: FinancialInstrument,
299
- ): string | undefined {
300
- if (instrument.lifecycle.states.length > UDL_LIMITS.financeStates) {
301
- return `financial analysis exceeds ${UDL_LIMITS.financeStates} lifecycle states`;
302
- }
303
- let transitionCount = 0;
304
- let transitionEdges = 0;
305
- for (const name in instrument.lifecycle.transitions) {
306
- if (!Object.hasOwn(instrument.lifecycle.transitions, name)) continue;
307
- transitionCount += 1;
308
- if (transitionCount > UDL_LIMITS.financeTransitions) {
309
- return `financial analysis exceeds ${UDL_LIMITS.financeTransitions} lifecycle transitions`;
310
- }
311
- transitionEdges += instrument.lifecycle.transitions[name]?.from.length ?? 0;
312
- if (transitionEdges > UDL_LIMITS.financeTransitionEdges) {
313
- return `financial analysis exceeds ${UDL_LIMITS.financeTransitionEdges} lifecycle transition edges`;
314
- }
315
- }
316
- let actionCount = 0;
317
- let effectCount = 0;
318
- for (const name in instrument.actions) {
319
- if (!Object.hasOwn(instrument.actions, name)) continue;
320
- actionCount += 1;
321
- if (actionCount > UDL_LIMITS.financeActions) {
322
- return `financial analysis exceeds ${UDL_LIMITS.financeActions} actions`;
323
- }
324
- const action = instrument.actions[name];
325
- effectCount += (action?.steps.length ?? 0) + (action?.moves?.length ?? 0);
326
- if (effectCount > UDL_LIMITS.financeEffects) {
327
- return `financial analysis exceeds ${UDL_LIMITS.financeEffects} kernel effects`;
328
- }
329
- }
330
- return undefined;
331
- }
332
-
333
- function productEscrowAccounts(instrument: FinancialInstrument): Set<string> {
334
- const accounts = new Set<string>();
335
- for (const action of Object.values(instrument.actions)) {
336
- for (const step of action.steps) {
337
- if (
338
- step.operation !== "account.escrow.provision" ||
339
- step.bind.role?.from !== "const" ||
340
- step.bind.role.value !== "product_escrow"
341
- ) {
342
- continue;
343
- }
344
- for (const [key, result] of Object.entries(step.capture ?? {})) {
345
- if (result === "accountId") accounts.add(`ref:${key}`);
346
- }
243
+ if (
244
+ Object.keys(action.set ?? {}).some((key) =>
245
+ instrument.fields.some((f) => f.name === key && f.type === "money"),
246
+ )
247
+ ) {
248
+ for (const account of owned) state.balances.set(account, null);
347
249
  }
250
+ };
251
+ const initial: State = {
252
+ status: instrument.lifecycle.initial,
253
+ actions: [],
254
+ paths: new Map(),
255
+ balances: new Map([...owned].map((key) => [key, new Map()])),
256
+ holds: new Map(),
257
+ };
258
+ if (instrument.actions.create) {
259
+ apply(initial, instrument.actions.create, "create");
260
+ for (const [account, balance] of initial.balances)
261
+ if (balance === null || balance.size)
262
+ initial.paths.set(account, ["create"]);
348
263
  }
349
-
350
- // `product_escrow` also backs open-ended balance products such as wallets.
351
- // Only instance-bound amounts claim static conservation; caller-sized flows
352
- // remain runtime balance checks. Commit sources are added separately above.
353
- return new Set(
354
- [...accounts].filter((account) =>
355
- Object.values(instrument.actions).some((action) =>
356
- (action.moves ?? []).some((step) => {
357
- if (
358
- (step.operation !== "internal_transfer.create" &&
359
- step.operation !== "internal_transfer.reserve") ||
360
- step.bind.amount?.from !== "instance"
361
- ) {
362
- return false;
363
- }
364
- return (
365
- canonicalAccount(instrument, step.bind.sourceAccountId) ===
366
- account ||
367
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
368
- account
369
- );
370
- }),
371
- ),
372
- ),
373
- );
374
- }
375
-
376
- function reservationsByKey(
377
- instrument: FinancialInstrument,
378
- ): ReadonlyMap<string, Reservation> {
379
- const reservations = new Map<string, Reservation>();
380
- for (const action of Object.values(instrument.actions)) {
381
- for (const step of action.moves ?? []) {
382
- if (step.operation !== "internal_transfer.reserve") continue;
383
- const source = canonicalAccount(instrument, step.bind.sourceAccountId);
384
- const destination = canonicalAccount(
385
- instrument,
386
- step.bind.destinationAccountId,
387
- );
388
- const amount = amountIdentity(step.bind.amount);
389
- for (const [key, result] of Object.entries(step.capture ?? {})) {
390
- if (result === "transferId") {
391
- reservations.set(key, {
392
- amount,
393
- ...(destination ? { destination } : {}),
394
- ...(source ? { source } : {}),
395
- });
396
- }
397
- }
264
+ const pending = [initial];
265
+ const reached = new Map<string, State>();
266
+ const copy = (state: State): State => ({
267
+ status: state.status,
268
+ actions: [...state.actions],
269
+ paths: new Map(state.paths),
270
+ balances: new Map(state.balances),
271
+ holds: new Map([...state.holds].map(([key, hold]) => [key, { ...hold }])),
272
+ });
273
+ let work = 0;
274
+ while (pending.length && !problems.length) {
275
+ if (++work > 4096) {
276
+ report("owned proof exceeds its finite work budget");
277
+ break;
398
278
  }
399
- }
400
- return reservations;
401
- }
402
-
403
- function effectsByAction(
404
- instrument: FinancialInstrument,
405
- account: string,
406
- reservations: ReadonlyMap<string, Reservation>,
407
- quotes: readonly QuoteFacts[],
408
- ): ReadonlyMap<string, readonly Effect[]> {
409
- const quoteByCommit = new Map(quotes.map((quote) => [quote.commit, quote]));
410
- const chargePaths = new Map(
411
- quotes.map((quote) => [`refs.${quote.chargeRef}`, quote]),
412
- );
413
- return new Map(
414
- Object.entries(instrument.actions).map(([actionName, action]) => [
415
- actionName,
416
- (action.moves ?? []).flatMap((step, stepIndex) => {
417
- const path = [
418
- "actions",
419
- actionName,
420
- "moves",
421
- stepIndex,
422
- "bind",
423
- ] as const;
424
- if (step.operation === "internal_transfer.create") {
425
- const effects: Effect[] = [];
426
- const amount = amountIdentity(step.bind.amount);
427
- if (
428
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
429
- account
430
- ) {
431
- effects.push({ amount, kind: "credit", path });
432
- }
433
- const source = canonicalAccount(
434
- instrument,
435
- step.bind.sourceAccountId,
436
- );
437
- if (source && accountsMayAlias(source, account)) {
438
- const committed = quoteByCommit.get(actionName);
439
- const amountPath =
440
- step.bind.amount?.from === "instance"
441
- ? step.bind.amount.path
442
- : undefined;
443
- const refunded =
444
- committed && amountPath === `refs.${committed.netRef}`
445
- ? committed
446
- : undefined;
447
- const charged = amountPath
448
- ? chargePaths.get(amountPath)
449
- : undefined;
450
- const kind = refunded
451
- ? "refund"
452
- : charged
453
- ? "penalty_debit"
454
- : amountPath !== undefined
455
- ? "debit"
456
- : "dynamic_debit";
457
- effects.push({
458
- amount:
459
- refunded && !refunded.chargeCanBeNonzero
460
- ? "refund_without_penalty"
461
- : amount,
462
- kind,
463
- path,
464
- ...(refunded ? { refundable: refunded.baseField } : {}),
465
- });
466
- }
467
- return effects;
279
+ let state = pending.pop()!;
280
+ const key = JSON.stringify([state.status, [...state.holds.keys()].sort()]);
281
+ const previous = reached.get(key);
282
+ if (previous) {
283
+ const joined = copy(previous);
284
+ let changed = false;
285
+ for (const account of owned) {
286
+ const before = previous.balances.has(account)
287
+ ? previous.balances.get(account)!
288
+ : new Map();
289
+ const after = state.balances.has(account)
290
+ ? state.balances.get(account)!
291
+ : new Map();
292
+ if (before !== null && !same(before, after)) {
293
+ joined.balances.set(account, null);
294
+ if (!before.size && state.paths.has(account))
295
+ joined.paths.set(account, state.paths.get(account)!);
296
+ changed = true;
468
297
  }
469
- if (step.operation === "internal_transfer.reserve") {
470
- const reservation =
471
- transferRefKey(step) ?? `${actionName}:${stepIndex}`;
472
- const effects: Effect[] = [];
473
- const source = canonicalAccount(
474
- instrument,
475
- step.bind.sourceAccountId,
476
- );
477
- if (source && accountsMayAlias(source, account)) {
478
- effects.push({
479
- amount: amountIdentity(step.bind.amount),
480
- kind: "outgoing_reserve",
481
- path,
482
- reservation,
483
- });
484
- }
485
- if (
486
- canonicalAccount(instrument, step.bind.destinationAccountId) ===
487
- account
488
- ) {
489
- effects.push({
490
- amount: amountIdentity(step.bind.amount),
491
- kind: "incoming_reserve",
492
- path,
493
- reservation,
494
- });
495
- }
496
- return effects;
497
- }
498
- if (
499
- step.operation === "internal_transfer.post" ||
500
- step.operation === "internal_transfer.void"
501
- ) {
502
- const reservation = boundTransferRefKey(step);
503
- if (!reservation) return [];
504
- const reserved = reservations.get(reservation);
505
- if (
506
- !reserved ||
507
- (!(reserved.source && accountsMayAlias(reserved.source, account)) &&
508
- reserved.destination !== account)
509
- ) {
510
- return [];
511
- }
512
- return [
513
- {
514
- amount: reserved.amount,
515
- kind:
516
- step.operation === "internal_transfer.post" ? "post" : "void",
517
- path,
518
- reservation,
519
- } satisfies Effect,
520
- ];
521
- }
522
- return [];
523
- }),
524
- ]),
525
- );
526
- }
527
-
528
- function applyEffects(
529
- account: string,
530
- action: string,
531
- input: AccountState,
532
- effects: readonly Effect[],
533
- reservations: ReadonlyMap<string, Reservation>,
534
- exactBalance: boolean,
535
- add: (path: readonly PropertyKey[], message: string) => void,
536
- ): AccountState {
537
- let balance = input.balance;
538
- const holds = { ...input.holds };
539
- const accountLabel = formatAccount(account);
540
- for (const effect of effects) {
541
- const fail = (message: string): void =>
542
- add([...effect.path, "sourceAccountId"], `action ${action} ${message}`);
543
- if (effect.kind === "credit") {
544
- if (
545
- exactBalance &&
546
- (balance.kind !== "empty" || Object.keys(holds).length > 0)
547
- ) {
548
- add(
549
- [...effect.path, "destinationAccountId"],
550
- `action ${action} funds ${accountLabel} while earlier value may remain; quoted funding must establish exactly one refundable balance`,
551
- );
552
- balance = UNKNOWN;
553
- continue;
554
298
  }
555
- const pieces = balance.kind === "funded" ? balance.amounts : [];
556
- const stacks =
557
- (balance.kind === "empty" || balance.kind === "funded") &&
558
- !pieces.includes(effect.amount);
559
- balance = stacks ? funded([...pieces, effect.amount]) : UNKNOWN;
560
- continue;
561
- }
562
- if (effect.kind === "incoming_reserve") continue;
563
- if (effect.kind === "outgoing_reserve") {
564
- const held =
565
- balance.kind === "funded" && balance.amounts.includes(effect.amount);
566
- if (!held) {
567
- fail(
568
- `cannot reserve ${effect.amount} from ${accountLabel}; that exact balance is not guaranteed`,
569
- );
570
- }
571
- holds[effect.reservation as string] = held
572
- ? { kind: "funded", amounts: [effect.amount] }
573
- : UNKNOWN;
574
- balance =
575
- held && balance.kind === "funded"
576
- ? funded(withoutPiece(balance.amounts, effect.amount))
577
- : EMPTY;
578
- continue;
579
- }
580
- if (effect.kind === "post" || effect.kind === "void") {
581
- const key = effect.reservation as string;
582
- const reservation = reservations.get(key);
583
- if (
584
- reservation?.source &&
585
- accountsMayAlias(reservation.source, account)
586
- ) {
587
- const held = holds[key];
588
- delete holds[key];
589
- if (effect.kind === "void" && held) {
590
- const pieces = balance.kind === "funded" ? balance.amounts : [];
591
- const restorable =
592
- held.kind === "funded" &&
593
- (balance.kind === "empty" ||
594
- (balance.kind === "funded" &&
595
- held.amounts.every((piece) => !pieces.includes(piece))));
596
- if (restorable && held.kind === "funded") {
597
- balance = funded([...pieces, ...held.amounts]);
598
- } else if (balance.kind === "empty") {
599
- balance = held;
600
- } else {
601
- add(
602
- [...effect.path, "transferId"],
603
- `action ${action} restores a held ${accountLabel} balance on top of existing value`,
604
- );
605
- balance = UNKNOWN;
606
- }
299
+ for (const [name, hold] of joined.holds) {
300
+ const next = state.holds.get(name)!;
301
+ if (hold.from !== next.from || hold.to !== next.to) {
302
+ report(`${name} changes reservation endpoints across paths`);
303
+ break;
607
304
  }
608
- }
609
- if (effect.kind === "post" && reservation?.destination === account) {
610
- if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
611
- add(
612
- [...effect.path, "transferId"],
613
- `action ${action} posts funding into ${accountLabel} while earlier value may remain`,
614
- );
615
- balance = UNKNOWN;
616
- } else {
617
- balance = { kind: "funded", amounts: [effect.amount] };
305
+ if (hold.amount !== null && !same(hold.amount, next.amount)) {
306
+ hold.amount = null;
307
+ changed = true;
618
308
  }
619
309
  }
620
- continue;
621
- }
622
- if (effect.kind === "refund") {
623
- const refundable = `fields.${effect.refundable ?? ""}`;
624
- if (
625
- balance.kind !== "funded" ||
626
- balance.amounts.length !== 1 ||
627
- balance.amounts[0] !== refundable
628
- ) {
629
- fail(
630
- `cannot refund ${refundable} from ${accountLabel}; that exact balance is not guaranteed`,
631
- );
632
- balance = UNKNOWN;
633
- } else {
634
- balance =
635
- effect.amount === "refund_without_penalty"
636
- ? EMPTY
637
- : { kind: "penalty" };
638
- }
639
- continue;
640
- }
641
- if (effect.kind === "penalty_debit") {
642
- if (balance.kind !== "penalty") {
643
- fail(
644
- `cannot pay the quoted charge from ${accountLabel} before the refund leaves that exact remainder`,
645
- );
646
- }
647
- balance = EMPTY;
648
- continue;
649
- }
650
- if (effect.kind === "dynamic_debit") {
651
- if (balance.kind === "empty") {
652
- fail(`can debit unfunded ${accountLabel}`);
653
- } else if (balance.kind === "penalty") {
654
- fail(
655
- `can debit ${accountLabel} after its refund left only the quoted charge`,
656
- );
657
- }
658
- balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
659
- continue;
660
- }
661
- if (balance.kind === "funded" && balance.amounts.includes(effect.amount)) {
662
- balance = funded(withoutPiece(balance.amounts, effect.amount));
663
- continue;
310
+ if (!changed) continue;
311
+ state = joined;
664
312
  }
665
- if (balance.kind === "empty") {
666
- fail(`can debit unfunded ${accountLabel}`);
667
- } else if (balance.kind === "penalty") {
668
- fail(
669
- `can debit ${accountLabel} after its refund left only the quoted charge`,
670
- );
671
- } else if (balance.kind === "funded" || exactBalance) {
672
- fail(
673
- `cannot prove ${effect.amount} is the exact available balance of ${accountLabel}`,
674
- );
675
- }
676
- balance = EMPTY;
677
- }
678
- return { balance, holds };
679
- }
680
-
681
- /**
682
- * Flattens every quote-commit pair the instrument declares. A quoting action
683
- * with no committing action, or a commit naming a non-quoting action, is a
684
- * validator failure rather than a typestate one, so it is simply skipped here.
685
- */
686
- function quoteFacts(
687
- instrument: FinancialInstrument,
688
- options: FinanceOptions,
689
- ): readonly QuoteFacts[] {
690
- const facts: QuoteFacts[] = [];
691
- for (const [quoting, action] of Object.entries(instrument.actions)) {
692
- const quote = action.quote;
693
- if (!quote) continue;
694
- const commit = Object.entries(instrument.actions).find(
695
- ([, candidate]) => candidate.commit === quoting,
696
- )?.[0];
697
- if (commit === undefined) continue;
698
- const chargePath = `refs.${quote.chargeRef}`;
699
- const declaresChargePayout = Object.values(instrument.actions).some(
700
- (candidate) =>
701
- (candidate.moves ?? []).some((move) =>
702
- Object.values(move.bind).some(
703
- (binding) =>
704
- binding.from === "instance" && binding.path === chargePath,
705
- ),
706
- ),
707
- );
708
- facts.push({
709
- baseField: quote.baseField,
710
- chargeCanBeNonzero:
711
- options.penaltyMayBeNonzero === true ||
712
- declaresChargePayout ||
713
- quote.charges.some((tier) => tier.bps > 0),
714
- chargeRef: quote.chargeRef,
715
- commit,
716
- netRef: quote.netRef,
717
- quoting,
718
- });
719
- }
720
- return facts;
721
- }
722
-
723
- /**
724
- * The quoted charge is the exact remainder the commit leaves behind, so it may
725
- * fund one transfer, out of the same account the refund left, in its own
726
- * one-way action directly after the commit.
727
- */
728
- function validateChargePayout(
729
- instrument: FinancialInstrument,
730
- quote: QuoteFacts,
731
- refundSource: string | undefined,
732
- add: (path: readonly PropertyKey[], message: string) => void,
733
- ): void {
734
- const chargePath = `refs.${quote.chargeRef}`;
735
- const uses = Object.entries(instrument.actions).flatMap(
736
- ([actionName, action]) =>
737
- (action.moves ?? []).flatMap((step, stepIndex) =>
738
- Object.entries(step.bind).flatMap(([target, binding]) =>
739
- binding.from === "instance" && binding.path === chargePath
740
- ? [{ binding, step, stepIndex, target, action, actionName }]
741
- : [],
742
- ),
743
- ),
744
- );
745
- if (quote.chargeCanBeNonzero && uses.length !== 1) {
746
- add(
747
- ["actions"],
748
- `${chargePath} is consumed ${uses.length} times; a nonzero charge schedule requires exactly one payout`,
749
- );
750
- }
751
- if (uses.length === 0) return;
752
- if (uses.length !== 1) {
753
- if (!quote.chargeCanBeNonzero) {
754
- add(
755
- ["actions"],
756
- `${chargePath} is consumed ${uses.length} times; it may fund at most one payout`,
757
- );
313
+ if (reached.size >= 256 && !previous) {
314
+ report("owned proof exceeds 256 state and reservation combinations");
315
+ break;
758
316
  }
759
- return;
760
- }
761
-
762
- const use = uses[0] as (typeof uses)[number];
763
- const payoutTransition = instrument.lifecycle.transitions[use.actionName];
764
- const commitTarget = instrument.lifecycle.transitions[quote.commit]?.to;
765
- const source = canonicalAccount(instrument, use.step.bind.sourceAccountId);
766
- const destination = canonicalAccount(
767
- instrument,
768
- use.step.bind.destinationAccountId,
769
- );
770
- const directAfterCommit =
771
- commitTarget !== undefined &&
772
- payoutTransition?.from.length === 1 &&
773
- payoutTransition.from[0] === commitTarget;
774
- const oneWay =
775
- payoutTransition !== undefined &&
776
- payoutTransition.to !== commitTarget &&
777
- !stateCanReach(instrument, payoutTransition.to, commitTarget);
778
- const valid =
779
- use.target === "amount" &&
780
- use.step.operation === "internal_transfer.create" &&
781
- (use.action.moves?.length ?? 0) === 1 &&
782
- directAfterCommit &&
783
- oneWay &&
784
- source !== undefined &&
785
- source === refundSource &&
786
- destination !== undefined &&
787
- !accountsMayAlias(source, destination);
788
- if (!valid) {
789
- add(
790
- ["actions", use.actionName, "moves", use.stepIndex, "bind", use.target],
791
- `${chargePath} must be the amount of one internal transfer from the quoted refund source to a different account, in its own one-way action directly after ${quote.commit}`,
317
+ reached.set(key, copy(state));
318
+ const edges = Object.entries(instrument.lifecycle.transitions).filter(
319
+ ([, edge]) => edge.from.includes(state.status),
792
320
  );
793
- }
794
- }
795
-
796
- function canonicalAccount(
797
- instrument: FinancialInstrument,
798
- binding: UdlBinding | undefined,
799
- ): string | undefined {
800
- if (binding?.from !== "instance") return undefined;
801
- const [root, key, extra] = binding.path.split(".");
802
- if (!key || extra !== undefined) return undefined;
803
- if (root === "fields") return `field:${key}`;
804
- if (root === "refs") return `ref:${key}`;
805
- if (root === "party") {
806
- const field =
807
- instrument.parties?.[
808
- key as keyof NonNullable<FinancialInstrument["parties"]>
809
- ];
810
- return field ? `field:${field}` : undefined;
811
- }
812
- return undefined;
813
- }
814
-
815
- function accountsMayAlias(left: string, right: string): boolean {
816
- if (left.startsWith("field:") && right.startsWith("field:")) return true;
817
- return left === right;
818
- }
819
-
820
- function amountIdentity(binding: UdlBinding | undefined): string {
821
- if (binding?.from === "instance") return binding.path;
822
- return binding?.from === "const" ? `const:${binding.value}` : "dynamic";
823
- }
824
-
825
- function transferRefKey(step: FinancialStep): string | undefined {
826
- return Object.entries(step.capture ?? {}).find(
827
- ([, result]) => result === "transferId",
828
- )?.[0];
829
- }
830
-
831
- function boundTransferRefKey(step: FinancialStep): string | undefined {
832
- const binding = step.bind.transferId;
833
- return binding?.from === "instance" && binding.path.startsWith("refs.")
834
- ? binding.path.slice("refs.".length)
835
- : undefined;
836
- }
837
-
838
- function stateCanReach(
839
- instrument: FinancialInstrument,
840
- start: string,
841
- target: string | undefined,
842
- ): boolean {
843
- if (!target) return false;
844
- const seen = new Set([start]);
845
- const pending = [start];
846
- while (pending.length > 0) {
847
- const state = pending.shift() as string;
848
- if (state === target) return true;
849
- for (const transition of Object.values(instrument.lifecycle.transitions)) {
850
- if (!transition.from.includes(state) || seen.has(transition.to)) continue;
851
- seen.add(transition.to);
852
- pending.push(transition.to);
321
+ if (
322
+ !edges.length &&
323
+ ([...state.balances].some(
324
+ ([account, balance]) =>
325
+ !shared.has(account) && (balance === null || balance.size > 0),
326
+ ) ||
327
+ state.holds.size)
328
+ )
329
+ problems.push({
330
+ path: ".actions",
331
+ message: `terminal state ${state.status} may strand owned money or an open reservation`,
332
+ stranded: {
333
+ state: state.status,
334
+ accounts: [...state.balances]
335
+ .filter(
336
+ ([account, balance]) =>
337
+ !shared.has(account) && (balance === null || balance.size > 0),
338
+ )
339
+ .map(([account]) => account.slice(5)),
340
+ actions: state.actions,
341
+ paths: Object.fromEntries(
342
+ [...state.paths].map(([account, path]) => [account.slice(5), path]),
343
+ ),
344
+ },
345
+ });
346
+ for (const [name, edge] of edges) {
347
+ const action = instrument.actions[name];
348
+ if (!action) continue;
349
+ const next = copy(state);
350
+ if (edge.to !== "preserve") next.status = edge.to;
351
+ apply(next, action, name);
352
+ next.actions.push(name);
353
+ for (const [account, balance] of next.balances) {
354
+ if (balance !== null && !balance.size) next.paths.delete(account);
355
+ else
356
+ next.paths.set(account, [
357
+ ...(state.paths.get(account) ?? state.actions),
358
+ name,
359
+ ]);
360
+ }
361
+ pending.push(next);
853
362
  }
854
363
  }
855
- return false;
856
- }
857
-
858
- function stateKey(state: AccountState): string {
859
- return JSON.stringify({
860
- balance: state.balance,
861
- holds: Object.fromEntries(
862
- Object.entries(state.holds).sort(([a], [b]) => a.localeCompare(b)),
863
- ),
864
- });
865
- }
866
-
867
- function formatAccount(account: string): string {
868
- const [kind, key] = account.split(":");
869
- return `${kind === "field" ? "fields" : "refs"}.${key}`;
364
+ return problems;
870
365
  }