@hyperscale0/udl 1.0.0-rc.1 → 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.
- package/CHANGELOG.md +5 -211
- package/LICENSE.md +356 -0
- package/NOTICE.md +8 -0
- package/README.md +14 -106
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +1 -29
- package/dist/canonical.js.map +1 -1
- package/dist/diagnostics.d.ts +13 -165
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +33 -157
- package/dist/diagnostics.js.map +1 -1
- package/dist/field-value.d.ts +5 -0
- package/dist/field-value.d.ts.map +1 -0
- package/dist/field-value.js +85 -0
- package/dist/field-value.js.map +1 -0
- package/dist/finance.d.ts +10 -53
- package/dist/finance.d.ts.map +1 -1
- package/dist/finance.js +252 -536
- package/dist/finance.js.map +1 -1
- package/dist/index.d.ts +9 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/json.d.ts +2 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +30 -0
- package/dist/json.js.map +1 -0
- package/dist/limits.d.ts +3 -28
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +5 -31
- package/dist/limits.js.map +1 -1
- package/dist/object-contract.d.ts +140 -0
- package/dist/object-contract.d.ts.map +1 -0
- package/dist/object-contract.js +314 -0
- package/dist/object-contract.js.map +1 -0
- package/dist/object-semantics-schema.d.ts +5710 -0
- package/dist/object-semantics-schema.d.ts.map +1 -0
- package/dist/object-semantics-schema.js +119 -0
- package/dist/object-semantics-schema.js.map +1 -0
- package/dist/object-semantics.d.ts +6 -0
- package/dist/object-semantics.d.ts.map +1 -0
- package/dist/object-semantics.js +279 -0
- package/dist/object-semantics.js.map +1 -0
- package/dist/object-structures.d.ts +4 -0
- package/dist/object-structures.d.ts.map +1 -0
- package/dist/object-structures.js +134 -0
- package/dist/object-structures.js.map +1 -0
- package/dist/reporting-validation.d.ts +8 -0
- package/dist/reporting-validation.d.ts.map +1 -0
- package/dist/reporting-validation.js +252 -0
- package/dist/reporting-validation.js.map +1 -0
- package/dist/reporting.d.ts +477 -0
- package/dist/reporting.d.ts.map +1 -0
- package/dist/reporting.js +169 -0
- package/dist/reporting.js.map +1 -0
- package/dist/schema.d.ts +5572 -2710
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +444 -1121
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +10 -73
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +1109 -2569
- package/dist/validation.js.map +1 -1
- package/docs/assets/brand/manifest.json +30 -0
- package/docs/assets/brand/udl-horizontal-white.svg +1 -0
- package/docs/assets/brand/udl-horizontal.svg +1 -0
- package/docs/assets/brand/udl-stacked-white.svg +1 -0
- package/docs/assets/brand/udl-stacked.svg +1 -0
- package/docs/assets/udl.svg +11 -0
- package/package.json +13 -22
- package/spec/README.md +300 -172
- package/spec/cars.udl.json +249 -0
- package/spec/udl.schema.json +3066 -2242
- package/src/canonical.ts +1 -35
- package/src/diagnostics.ts +76 -209
- package/src/field-value.ts +87 -0
- package/src/finance.ts +327 -832
- package/src/index.ts +18 -95
- package/src/json.ts +34 -0
- package/src/limits.ts +5 -31
- package/src/object-contract.ts +559 -0
- package/src/object-semantics-schema.ts +148 -0
- package/src/object-semantics.ts +378 -0
- package/src/object-structures.ts +255 -0
- package/src/reporting-validation.ts +383 -0
- package/src/reporting.ts +178 -0
- package/src/schema.ts +510 -1277
- package/src/validation.ts +1630 -4545
- package/LICENSE +0 -661
- package/LICENSING.md +0 -51
- package/TRADEMARKS.md +0 -35
- package/conformance/README.md +0 -82
- package/conformance/evolution/action-contract.expected.json +0 -10
- package/conformance/evolution/action-contract.live.udl +0 -44
- package/conformance/evolution/action-contract.next.udl +0 -45
- package/conformance/evolution/product-identity.expected.json +0 -10
- package/conformance/evolution/product-identity.live.udl +0 -55
- package/conformance/evolution/product-identity.next.udl +0 -55
- package/conformance/evolution/version-required.expected.json +0 -10
- package/conformance/evolution/version-required.live.udl +0 -55
- package/conformance/evolution/version-required.next.udl +0 -58
- package/conformance/invalid/action-without-transition.expected.json +0 -10
- package/conformance/invalid/action-without-transition.udl +0 -60
- package/conformance/invalid/agent-description-too-long.expected.json +0 -10
- package/conformance/invalid/agent-description-too-long.udl +0 -57
- package/conformance/invalid/blank-title.expected.json +0 -10
- package/conformance/invalid/blank-title.udl +0 -54
- package/conformance/invalid/composition-dial-duplicate-key.expected.json +0 -10
- package/conformance/invalid/composition-dial-duplicate-key.udl +0 -73
- package/conformance/invalid/depth-budget.expected.json +0 -10
- package/conformance/invalid/depth-budget.udl +0 -49
- package/conformance/invalid/duplicate-subject.expected.json +0 -10
- package/conformance/invalid/duplicate-subject.udl +0 -230
- package/conformance/invalid/forged-effects.expected.json +0 -10
- package/conformance/invalid/forged-effects.udl +0 -76
- package/conformance/invalid/format-version.expected.json +0 -10
- package/conformance/invalid/format-version.udl +0 -54
- package/conformance/invalid/instrument-id-not-snake-case.expected.json +0 -10
- package/conformance/invalid/instrument-id-not-snake-case.udl +0 -54
- package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +0 -10
- package/conformance/invalid/invalid-aggregate-gate-shape.udl +0 -1819
- package/conformance/invalid/invalid-check-duration.expected.json +0 -10
- package/conformance/invalid/invalid-check-duration.udl +0 -219
- package/conformance/invalid/invalid-dial-anchor.expected.json +0 -10
- package/conformance/invalid/invalid-dial-anchor.udl +0 -219
- package/conformance/invalid/invalid-exception-parent-ref.expected.json +0 -10
- package/conformance/invalid/invalid-exception-parent-ref.udl +0 -2487
- package/conformance/invalid/invalid-exposure-shape.expected.json +0 -10
- package/conformance/invalid/invalid-exposure-shape.udl +0 -1819
- package/conformance/invalid/invalid-remainder.expected.json +0 -10
- package/conformance/invalid/invalid-remainder.udl +0 -220
- package/conformance/invalid/invalid-schema-keyword.expected.json +0 -10
- package/conformance/invalid/invalid-schema-keyword.udl +0 -220
- package/conformance/invalid/invalid-utf8.expected.json +0 -10
- package/conformance/invalid/invalid-utf8.udl +0 -1
- package/conformance/invalid/malformed-json.expected.json +0 -10
- package/conformance/invalid/malformed-json.udl +0 -1
- package/conformance/invalid/missing-create-action.expected.json +0 -10
- package/conformance/invalid/missing-create-action.udl +0 -48
- package/conformance/invalid/missing-exception-amount-field.expected.json +0 -10
- package/conformance/invalid/missing-exception-amount-field.udl +0 -2487
- package/conformance/invalid/missing-exception-contract.expected.json +0 -14
- package/conformance/invalid/missing-exception-contract.udl +0 -2523
- package/conformance/invalid/missing-exception-reason-field.expected.json +0 -10
- package/conformance/invalid/missing-exception-reason-field.udl +0 -2487
- package/conformance/invalid/not-an-object.expected.json +0 -10
- package/conformance/invalid/not-an-object.udl +0 -1
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +0 -10
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +0 -259
- package/conformance/invalid/quote-freeze-set-incomplete.expected.json +0 -10
- package/conformance/invalid/quote-freeze-set-incomplete.udl +0 -261
- package/conformance/invalid/quote-named-reference-gate.expected.json +0 -10
- package/conformance/invalid/quote-named-reference-gate.udl +0 -50
- package/conformance/invalid/reconcile-named-reference-gate.expected.json +0 -10
- package/conformance/invalid/reconcile-named-reference-gate.udl +0 -50
- package/conformance/invalid/unknown-key.expected.json +0 -10
- package/conformance/invalid/unknown-key.udl +0 -55
- package/conformance/invalid/unknown-reference-gate-field.expected.json +0 -10
- package/conformance/invalid/unknown-reference-gate-field.udl +0 -1819
- package/conformance/invalid/unknown-required-field.expected.json +0 -10
- package/conformance/invalid/unknown-required-field.udl +0 -220
- package/conformance/invalid/unreachable-state.expected.json +0 -10
- package/conformance/invalid/unreachable-state.udl +0 -55
- package/conformance/invalid/wrong-exception-amount-field.expected.json +0 -10
- package/conformance/invalid/wrong-exception-amount-field.udl +0 -2487
- package/conformance/invalid/wrong-exception-reason-field.expected.json +0 -10
- package/conformance/invalid/wrong-exception-reason-field.udl +0 -2487
- package/conformance/valid/agent-description.expected.json +0 -6
- package/conformance/valid/agent-description.udl +0 -66
- package/conformance/valid/cards.expected.json +0 -6
- package/conformance/valid/cards.udl +0 -1579
- package/conformance/valid/commerce-escrow.expected.json +0 -6
- package/conformance/valid/commerce-escrow.udl +0 -1512
- package/conformance/valid/compiled-crowdfunding.expected.json +0 -6
- package/conformance/valid/compiled-crowdfunding.udl +0 -1843
- package/conformance/valid/compiled-watch-club.expected.json +0 -6
- package/conformance/valid/compiled-watch-club.udl +0 -2486
- package/conformance/valid/complete-contract.expected.json +0 -6
- package/conformance/valid/complete-contract.udl +0 -218
- package/conformance/valid/effect-signatures.expected.json +0 -6
- package/conformance/valid/effect-signatures.udl +0 -75
- package/conformance/valid/hand-edited.expected.json +0 -6
- package/conformance/valid/hand-edited.udl +0 -1
- package/conformance/valid/insured-car-marketplace.expected.json +0 -6
- package/conformance/valid/insured-car-marketplace.udl +0 -1050
- package/conformance/valid/insured-travel.expected.json +0 -6
- package/conformance/valid/insured-travel.udl +0 -3469
- package/conformance/valid/minimal.expected.json +0 -6
- package/conformance/valid/minimal.udl +0 -54
- package/conformance/valid/protection.expected.json +0 -6
- package/conformance/valid/protection.udl +0 -1551
- package/conformance/valid/string-escaping.expected.json +0 -6
- package/conformance/valid/string-escaping.udl +0 -54
- package/dist/check-profiles.d.ts +0 -57
- package/dist/check-profiles.d.ts.map +0 -1
- package/dist/check-profiles.js +0 -62
- package/dist/check-profiles.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -184
- package/dist/cli.js.map +0 -1
- package/dist/effects.d.ts +0 -41
- package/dist/effects.d.ts.map +0 -1
- package/dist/effects.js +0 -110
- package/dist/effects.js.map +0 -1
- package/dist/evolution.d.ts +0 -120
- package/dist/evolution.d.ts.map +0 -1
- package/dist/evolution.js +0 -567
- package/dist/evolution.js.map +0 -1
- package/dist/parser.d.ts +0 -4
- package/dist/parser.d.ts.map +0 -1
- package/dist/parser.js +0 -44
- package/dist/parser.js.map +0 -1
- package/docs/README.md +0 -14
- package/docs/guide/01-a-document.md +0 -37
- package/docs/guide/02-money-steps.md +0 -23
- package/docs/guide/03-laws.md +0 -18
- package/docs/guide/04-fees-and-remainder.md +0 -15
- package/docs/guide/05-checks-updates-dials.md +0 -7
- package/docs/guide/06-effects.md +0 -11
- package/docs/guide/07-evolution.md +0 -11
- package/docs/guide/08-implementing.md +0 -13
- package/docs/llms-full.txt +0 -1241
- package/docs/llms.txt +0 -14
- package/docs/reference/canonical.md +0 -16
- package/docs/reference/clauses.md +0 -986
- package/docs/reference/cli.md +0 -22
- package/docs/reference/diagnostics.md +0 -32
- package/skills/udl/SKILL.md +0 -28
- package/src/check-profiles.ts +0 -80
- package/src/cli.ts +0 -196
- package/src/effects.ts +0 -163
- package/src/evolution.ts +0 -965
- package/src/parser.ts +0 -58
package/src/finance.ts
CHANGED
|
@@ -1,870 +1,365 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type {
|
|
2
|
+
UdlAction,
|
|
3
|
+
UdlDocument,
|
|
4
|
+
UdlInstrument,
|
|
5
|
+
UdlValue,
|
|
6
|
+
} from "./schema.js";
|
|
4
7
|
|
|
5
8
|
export interface FinanceIssue {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
556
|
-
|
|
557
|
-
(
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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 (
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
)
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
|
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
|
}
|