@hyperscale0/udl 1.0.0-beta.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -25
- package/README.md +37 -99
- package/conformance/README.md +44 -41
- package/conformance/evolution/action-contract.expected.json +10 -0
- package/conformance/evolution/action-contract.live.udl +44 -0
- package/conformance/evolution/action-contract.next.udl +45 -0
- package/conformance/evolution/product-identity.expected.json +10 -0
- package/conformance/evolution/product-identity.live.udl +55 -0
- package/conformance/evolution/product-identity.next.udl +55 -0
- package/conformance/evolution/version-required.expected.json +10 -0
- package/conformance/evolution/version-required.live.udl +55 -0
- package/conformance/evolution/version-required.next.udl +58 -0
- package/conformance/invalid/action-without-transition.expected.json +10 -0
- package/conformance/invalid/{verb-without-transition.udl → action-without-transition.udl} +7 -2
- package/conformance/invalid/agent-description-too-long.expected.json +10 -0
- package/conformance/invalid/agent-description-too-long.udl +57 -0
- package/conformance/invalid/blank-title.expected.json +1 -1
- package/conformance/invalid/blank-title.udl +6 -2
- package/conformance/invalid/composition-dial-duplicate-key.expected.json +10 -0
- package/conformance/invalid/composition-dial-duplicate-key.udl +73 -0
- package/conformance/invalid/depth-budget.expected.json +1 -1
- package/conformance/invalid/duplicate-subject.expected.json +10 -0
- package/conformance/invalid/duplicate-subject.udl +230 -0
- package/conformance/invalid/forged-effects.expected.json +10 -0
- package/conformance/invalid/forged-effects.udl +76 -0
- package/conformance/invalid/format-version.expected.json +1 -1
- package/conformance/invalid/format-version.udl +6 -2
- package/conformance/invalid/instrument-id-not-snake-case.expected.json +10 -0
- package/conformance/invalid/{noun-id-not-snake-case.udl → instrument-id-not-snake-case.udl} +6 -2
- package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +10 -0
- package/conformance/invalid/invalid-aggregate-gate-shape.udl +1819 -0
- package/conformance/invalid/invalid-check-duration.expected.json +10 -0
- package/conformance/invalid/invalid-check-duration.udl +219 -0
- package/conformance/invalid/invalid-dial-anchor.expected.json +10 -0
- package/conformance/invalid/invalid-dial-anchor.udl +219 -0
- package/conformance/invalid/invalid-exception-parent-ref.expected.json +10 -0
- package/conformance/invalid/invalid-exception-parent-ref.udl +2487 -0
- package/conformance/invalid/invalid-exposure-shape.expected.json +10 -0
- package/conformance/invalid/invalid-exposure-shape.udl +1819 -0
- package/conformance/invalid/invalid-remainder.expected.json +10 -0
- package/conformance/invalid/invalid-remainder.udl +220 -0
- package/conformance/invalid/invalid-schema-keyword.expected.json +10 -0
- package/conformance/invalid/invalid-schema-keyword.udl +220 -0
- package/conformance/invalid/invalid-utf8.expected.json +1 -1
- package/conformance/invalid/malformed-json.expected.json +1 -1
- package/conformance/invalid/missing-create-action.expected.json +10 -0
- package/conformance/invalid/{missing-create-verb.udl → missing-create-action.udl} +5 -2
- package/conformance/invalid/missing-exception-amount-field.expected.json +10 -0
- package/conformance/invalid/missing-exception-amount-field.udl +2487 -0
- package/conformance/invalid/missing-exception-contract.expected.json +14 -0
- package/conformance/invalid/missing-exception-contract.udl +2523 -0
- package/conformance/invalid/missing-exception-reason-field.expected.json +10 -0
- package/conformance/invalid/missing-exception-reason-field.udl +2487 -0
- package/conformance/invalid/not-an-object.expected.json +1 -1
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +10 -0
- package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +259 -0
- package/conformance/invalid/quote-freeze-set-incomplete.expected.json +10 -0
- package/conformance/invalid/quote-freeze-set-incomplete.udl +261 -0
- package/conformance/invalid/quote-named-reference-gate.expected.json +10 -0
- package/conformance/invalid/quote-named-reference-gate.udl +50 -0
- package/conformance/invalid/reconcile-named-reference-gate.expected.json +10 -0
- package/conformance/invalid/reconcile-named-reference-gate.udl +50 -0
- package/conformance/invalid/unknown-key.expected.json +1 -1
- package/conformance/invalid/unknown-key.udl +6 -2
- package/conformance/invalid/unknown-reference-gate-field.expected.json +10 -0
- package/conformance/invalid/unknown-reference-gate-field.udl +1819 -0
- package/conformance/invalid/unknown-required-field.expected.json +10 -0
- package/conformance/invalid/unknown-required-field.udl +220 -0
- package/conformance/invalid/unreachable-state.expected.json +2 -2
- package/conformance/invalid/unreachable-state.udl +6 -2
- package/conformance/invalid/wrong-exception-amount-field.expected.json +10 -0
- package/conformance/invalid/wrong-exception-amount-field.udl +2487 -0
- package/conformance/invalid/wrong-exception-reason-field.expected.json +10 -0
- package/conformance/invalid/wrong-exception-reason-field.udl +2487 -0
- package/conformance/valid/agent-description.expected.json +6 -0
- package/conformance/valid/agent-description.udl +66 -0
- package/conformance/valid/cards.expected.json +1 -0
- package/conformance/valid/cards.udl +843 -356
- package/conformance/valid/commerce-escrow.expected.json +1 -0
- package/conformance/valid/commerce-escrow.udl +792 -291
- package/conformance/valid/compiled-crowdfunding.expected.json +6 -0
- package/conformance/valid/compiled-crowdfunding.udl +1843 -0
- package/conformance/valid/compiled-watch-club.expected.json +6 -0
- package/conformance/valid/compiled-watch-club.udl +2486 -0
- package/conformance/valid/complete-contract.expected.json +6 -0
- package/conformance/valid/complete-contract.udl +218 -0
- package/conformance/valid/effect-signatures.expected.json +6 -0
- package/conformance/valid/effect-signatures.udl +75 -0
- package/conformance/valid/hand-edited.expected.json +1 -0
- package/conformance/valid/hand-edited.udl +1 -1
- package/conformance/valid/insured-car-marketplace.expected.json +1 -0
- package/conformance/valid/insured-car-marketplace.udl +534 -315
- package/conformance/valid/insured-travel.expected.json +2 -1
- package/conformance/valid/insured-travel.udl +1489 -641
- package/conformance/valid/minimal.expected.json +1 -0
- package/conformance/valid/minimal.udl +18 -14
- package/conformance/valid/protection.expected.json +1 -0
- package/conformance/valid/protection.udl +719 -304
- package/conformance/valid/string-escaping.expected.json +6 -0
- package/conformance/valid/string-escaping.udl +54 -0
- package/dist/canonical.d.ts +2 -0
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +7 -0
- package/dist/canonical.js.map +1 -1
- package/dist/check-profiles.d.ts +57 -0
- package/dist/check-profiles.d.ts.map +1 -0
- package/dist/check-profiles.js +62 -0
- package/dist/check-profiles.js.map +1 -0
- package/dist/cli.js +48 -4
- package/dist/cli.js.map +1 -1
- package/dist/diagnostics.d.ts +168 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +164 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/effects.d.ts +41 -0
- package/dist/effects.d.ts.map +1 -0
- package/dist/effects.js +110 -0
- package/dist/effects.js.map +1 -0
- package/dist/evolution.d.ts +51 -18
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +244 -102
- package/dist/evolution.js.map +1 -1
- package/dist/finance.d.ts +17 -13
- package/dist/finance.d.ts.map +1 -1
- package/dist/finance.js +165 -109
- package/dist/finance.js.map +1 -1
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +2 -2
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +4 -3
- package/dist/limits.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +6 -16
- package/dist/parser.js.map +1 -1
- package/dist/schema.d.ts +2059 -135
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +759 -73
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +22 -9
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +1463 -457
- package/dist/validation.js.map +1 -1
- package/docs/README.md +14 -0
- package/docs/guide/01-a-document.md +37 -0
- package/docs/guide/02-money-steps.md +23 -0
- package/docs/guide/03-laws.md +18 -0
- package/docs/guide/04-fees-and-remainder.md +15 -0
- package/docs/guide/05-checks-updates-dials.md +7 -0
- package/docs/guide/06-effects.md +11 -0
- package/docs/guide/07-evolution.md +11 -0
- package/docs/guide/08-implementing.md +13 -0
- package/docs/llms-full.txt +1241 -0
- package/docs/llms.txt +14 -0
- package/docs/reference/canonical.md +16 -0
- package/docs/reference/clauses.md +986 -0
- package/docs/reference/cli.md +22 -0
- package/docs/reference/diagnostics.md +32 -0
- package/package.json +6 -2
- package/skills/udl/SKILL.md +28 -0
- package/spec/README.md +82 -51
- package/spec/udl.schema.json +1191 -119
- package/src/canonical.ts +11 -0
- package/src/check-profiles.ts +80 -0
- package/src/cli.ts +49 -3
- package/src/diagnostics.ts +221 -0
- package/src/effects.ts +163 -0
- package/src/evolution.ts +375 -125
- package/src/finance.ts +255 -160
- package/src/index.ts +50 -14
- package/src/limits.ts +4 -3
- package/src/parser.ts +15 -18
- package/src/schema.ts +855 -81
- package/src/validation.ts +2666 -560
- package/conformance/invalid/missing-create-verb.expected.json +0 -10
- package/conformance/invalid/noun-id-not-snake-case.expected.json +0 -10
- package/conformance/invalid/verb-without-transition.expected.json +0 -10
package/dist/finance.js
CHANGED
|
@@ -14,90 +14,91 @@ function withoutPiece(amounts, amount) {
|
|
|
14
14
|
return amounts.filter((_, position) => position !== index);
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* One financial typestate owner for canonical UDL and static
|
|
17
|
+
* One financial typestate owner for canonical UDL and static instrument contracts.
|
|
18
18
|
* It proves a tracked escrow account holds an exact set of funded amounts,
|
|
19
19
|
* models held transfers through reserve/post/void, consumes every direct
|
|
20
|
-
* debit piece by piece, and makes the
|
|
20
|
+
* debit piece by piece, and makes the quoted refund's sole remainder an
|
|
21
21
|
* exactly-once penalty payout.
|
|
22
22
|
*/
|
|
23
|
-
export function
|
|
23
|
+
export function analyzeInstrumentFinance(instrument, options = {}) {
|
|
24
24
|
const issues = [];
|
|
25
25
|
const seenIssues = new Set();
|
|
26
|
-
const add = (path, message) => {
|
|
26
|
+
const add = (path, message, code = "UDL4001") => {
|
|
27
27
|
const key = `${path.join(".")}\0${message}`;
|
|
28
28
|
if (seenIssues.has(key))
|
|
29
29
|
return;
|
|
30
30
|
seenIssues.add(key);
|
|
31
|
-
issues.push({ message, path });
|
|
31
|
+
issues.push({ code, message, path });
|
|
32
32
|
};
|
|
33
|
-
const admissionProblem = financeAdmissionProblem(
|
|
33
|
+
const admissionProblem = financeAdmissionProblem(instrument);
|
|
34
34
|
if (admissionProblem) {
|
|
35
|
-
add(["lifecycle"], admissionProblem);
|
|
35
|
+
add(["lifecycle"], admissionProblem, "UDL1004");
|
|
36
36
|
return issues;
|
|
37
37
|
}
|
|
38
|
-
const trackedAccounts =
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
const trackedAccounts = productEscrowAccounts(instrument);
|
|
39
|
+
const quotes = quoteFacts(instrument, options);
|
|
40
|
+
const refundSources = new Map();
|
|
41
|
+
for (const quote of quotes) {
|
|
42
|
+
const commitTransfer = instrument.actions[quote.commit]?.moves?.filter((move) => move.operation === "internal_transfer.create")[0];
|
|
43
|
+
const source = canonicalAccount(instrument, commitTransfer?.bind.sourceAccountId);
|
|
44
|
+
if (!source)
|
|
45
|
+
continue;
|
|
46
|
+
refundSources.set(quote.commit, source);
|
|
47
|
+
trackedAccounts.add(source);
|
|
48
|
+
}
|
|
49
|
+
const refundSourceAccounts = new Set(refundSources.values());
|
|
45
50
|
if (trackedAccounts.size === 0)
|
|
46
51
|
return [];
|
|
47
52
|
if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
|
|
48
|
-
add(["
|
|
53
|
+
add(["actions"], `financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`, "UDL1004");
|
|
49
54
|
return issues;
|
|
50
55
|
}
|
|
51
|
-
const reservations = reservationsByKey(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
declaresPenaltyPayout ||
|
|
55
|
-
noun.unwind?.penalty.some((tier) => tier.bps > 0) === true;
|
|
56
|
-
if (noun.unwind) {
|
|
57
|
-
validatePenaltyPayout(noun, refundSource, penaltyCanBeNonzero, add);
|
|
56
|
+
const reservations = reservationsByKey(instrument);
|
|
57
|
+
for (const quote of quotes) {
|
|
58
|
+
validateChargePayout(instrument, quote, refundSources.get(quote.commit), add);
|
|
58
59
|
}
|
|
59
60
|
let pathVariants = 0;
|
|
60
61
|
let work = 0;
|
|
61
62
|
for (const account of trackedAccounts) {
|
|
62
|
-
const effects =
|
|
63
|
+
const effects = effectsByAction(instrument, account, reservations, quotes);
|
|
63
64
|
const createEffects = effects.get("create") ?? [];
|
|
64
65
|
work += 1 + createEffects.length;
|
|
65
66
|
if (work > UDL_LIMITS.financeWork) {
|
|
66
|
-
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units
|
|
67
|
+
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`, "UDL1004");
|
|
67
68
|
return issues;
|
|
68
69
|
}
|
|
69
|
-
const initial = applyEffects(
|
|
70
|
+
const initial = applyEffects(account, "create", { balance: EMPTY, holds: {} }, createEffects, reservations, refundSourceAccounts.has(account), add);
|
|
70
71
|
const states = new Map([
|
|
71
|
-
[
|
|
72
|
+
[instrument.lifecycle.initial, new Map([[stateKey(initial), initial]])],
|
|
72
73
|
]);
|
|
73
74
|
pathVariants += 1;
|
|
74
|
-
const pending = [
|
|
75
|
+
const pending = [instrument.lifecycle.initial];
|
|
75
76
|
while (pending.length > 0) {
|
|
76
77
|
const from = pending.shift();
|
|
77
78
|
const sourceStates = states.get(from);
|
|
78
79
|
if (!sourceStates)
|
|
79
80
|
continue;
|
|
80
|
-
for (const [
|
|
81
|
+
for (const [action, transition] of Object.entries(instrument.lifecycle.transitions)) {
|
|
81
82
|
if (!transition.from.includes(from))
|
|
82
83
|
continue;
|
|
83
84
|
const targetStates = states.get(transition.to) ?? new Map();
|
|
84
85
|
states.set(transition.to, targetStates);
|
|
85
86
|
let grew = false;
|
|
86
87
|
for (const sourceState of sourceStates.values()) {
|
|
87
|
-
const transitionEffects = effects.get(
|
|
88
|
+
const transitionEffects = effects.get(action) ?? [];
|
|
88
89
|
work += 1 + transitionEffects.length;
|
|
89
90
|
if (work > UDL_LIMITS.financeWork) {
|
|
90
|
-
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units
|
|
91
|
+
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`, "UDL1004");
|
|
91
92
|
return issues;
|
|
92
93
|
}
|
|
93
|
-
const targetState = applyEffects(
|
|
94
|
+
const targetState = applyEffects(account, action, sourceState, transitionEffects, reservations, refundSourceAccounts.has(account), add);
|
|
94
95
|
const key = stateKey(targetState);
|
|
95
96
|
if (targetStates.has(key))
|
|
96
97
|
continue;
|
|
97
98
|
targetStates.set(key, targetState);
|
|
98
99
|
pathVariants += 1;
|
|
99
100
|
if (pathVariants > UDL_LIMITS.financePathVariants) {
|
|
100
|
-
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants
|
|
101
|
+
add(["lifecycle"], `financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`, "UDL1004");
|
|
101
102
|
return issues;
|
|
102
103
|
}
|
|
103
104
|
grew = true;
|
|
@@ -106,7 +107,7 @@ export function analyzeNounFinance(noun, options = {}) {
|
|
|
106
107
|
pending.push(transition.to);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
|
-
const nonterminalStates = new Set(Object.values(
|
|
110
|
+
const nonterminalStates = new Set(Object.values(instrument.lifecycle.transitions).flatMap((transition) => transition.from));
|
|
110
111
|
for (const [state, variants] of states) {
|
|
111
112
|
if (nonterminalStates.has(state))
|
|
112
113
|
continue;
|
|
@@ -115,54 +116,54 @@ export function analyzeNounFinance(noun, options = {}) {
|
|
|
115
116
|
Object.keys(variant.holds).length === 0) {
|
|
116
117
|
continue;
|
|
117
118
|
}
|
|
118
|
-
add(["lifecycle", "states",
|
|
119
|
+
add(["lifecycle", "states", instrument.lifecycle.states.indexOf(state)], `terminal state ${state} can strand value in ${formatAccount(account)}`);
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
return issues;
|
|
123
124
|
}
|
|
124
|
-
export function financeAdmissionProblem(
|
|
125
|
-
if (
|
|
125
|
+
export function financeAdmissionProblem(instrument) {
|
|
126
|
+
if (instrument.lifecycle.states.length > UDL_LIMITS.financeStates) {
|
|
126
127
|
return `financial analysis exceeds ${UDL_LIMITS.financeStates} lifecycle states`;
|
|
127
128
|
}
|
|
128
129
|
let transitionCount = 0;
|
|
129
130
|
let transitionEdges = 0;
|
|
130
|
-
for (const name in
|
|
131
|
-
if (!Object.hasOwn(
|
|
131
|
+
for (const name in instrument.lifecycle.transitions) {
|
|
132
|
+
if (!Object.hasOwn(instrument.lifecycle.transitions, name))
|
|
132
133
|
continue;
|
|
133
134
|
transitionCount += 1;
|
|
134
135
|
if (transitionCount > UDL_LIMITS.financeTransitions) {
|
|
135
136
|
return `financial analysis exceeds ${UDL_LIMITS.financeTransitions} lifecycle transitions`;
|
|
136
137
|
}
|
|
137
|
-
transitionEdges +=
|
|
138
|
+
transitionEdges += instrument.lifecycle.transitions[name]?.from.length ?? 0;
|
|
138
139
|
if (transitionEdges > UDL_LIMITS.financeTransitionEdges) {
|
|
139
140
|
return `financial analysis exceeds ${UDL_LIMITS.financeTransitionEdges} lifecycle transition edges`;
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
|
-
let
|
|
143
|
+
let actionCount = 0;
|
|
143
144
|
let effectCount = 0;
|
|
144
|
-
for (const name in
|
|
145
|
-
if (!Object.hasOwn(
|
|
145
|
+
for (const name in instrument.actions) {
|
|
146
|
+
if (!Object.hasOwn(instrument.actions, name))
|
|
146
147
|
continue;
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
return `financial analysis exceeds ${UDL_LIMITS.
|
|
148
|
+
actionCount += 1;
|
|
149
|
+
if (actionCount > UDL_LIMITS.financeActions) {
|
|
150
|
+
return `financial analysis exceeds ${UDL_LIMITS.financeActions} actions`;
|
|
150
151
|
}
|
|
151
|
-
const
|
|
152
|
-
effectCount += (
|
|
152
|
+
const action = instrument.actions[name];
|
|
153
|
+
effectCount += (action?.steps.length ?? 0) + (action?.moves?.length ?? 0);
|
|
153
154
|
if (effectCount > UDL_LIMITS.financeEffects) {
|
|
154
155
|
return `financial analysis exceeds ${UDL_LIMITS.financeEffects} kernel effects`;
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
return undefined;
|
|
158
159
|
}
|
|
159
|
-
function
|
|
160
|
+
function productEscrowAccounts(instrument) {
|
|
160
161
|
const accounts = new Set();
|
|
161
|
-
for (const
|
|
162
|
-
for (const step of
|
|
162
|
+
for (const action of Object.values(instrument.actions)) {
|
|
163
|
+
for (const step of action.steps) {
|
|
163
164
|
if (step.operation !== "account.escrow.provision" ||
|
|
164
165
|
step.bind.role?.from !== "const" ||
|
|
165
|
-
step.bind.role.value !== "
|
|
166
|
+
step.bind.role.value !== "product_escrow") {
|
|
166
167
|
continue;
|
|
167
168
|
}
|
|
168
169
|
for (const [key, result] of Object.entries(step.capture ?? {})) {
|
|
@@ -171,27 +172,29 @@ function workspaceEscrowAccounts(noun) {
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
|
-
// `
|
|
175
|
+
// `product_escrow` also backs open-ended balance products such as wallets.
|
|
175
176
|
// Only instance-bound amounts claim static conservation; caller-sized flows
|
|
176
|
-
// remain runtime balance checks.
|
|
177
|
-
return new Set([...accounts].filter((account) => Object.values(
|
|
177
|
+
// remain runtime balance checks. Commit sources are added separately above.
|
|
178
|
+
return new Set([...accounts].filter((account) => Object.values(instrument.actions).some((action) => (action.moves ?? []).some((step) => {
|
|
178
179
|
if ((step.operation !== "internal_transfer.create" &&
|
|
179
180
|
step.operation !== "internal_transfer.reserve") ||
|
|
180
181
|
step.bind.amount?.from !== "instance") {
|
|
181
182
|
return false;
|
|
182
183
|
}
|
|
183
|
-
return (canonicalAccount(
|
|
184
|
-
|
|
184
|
+
return (canonicalAccount(instrument, step.bind.sourceAccountId) ===
|
|
185
|
+
account ||
|
|
186
|
+
canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
187
|
+
account);
|
|
185
188
|
}))));
|
|
186
189
|
}
|
|
187
|
-
function reservationsByKey(
|
|
190
|
+
function reservationsByKey(instrument) {
|
|
188
191
|
const reservations = new Map();
|
|
189
|
-
for (const
|
|
190
|
-
for (const step of
|
|
192
|
+
for (const action of Object.values(instrument.actions)) {
|
|
193
|
+
for (const step of action.moves ?? []) {
|
|
191
194
|
if (step.operation !== "internal_transfer.reserve")
|
|
192
195
|
continue;
|
|
193
|
-
const source = canonicalAccount(
|
|
194
|
-
const destination = canonicalAccount(
|
|
196
|
+
const source = canonicalAccount(instrument, step.bind.sourceAccountId);
|
|
197
|
+
const destination = canonicalAccount(instrument, step.bind.destinationAccountId);
|
|
195
198
|
const amount = amountIdentity(step.bind.amount);
|
|
196
199
|
for (const [key, result] of Object.entries(step.capture ?? {})) {
|
|
197
200
|
if (result === "transferId") {
|
|
@@ -206,43 +209,60 @@ function reservationsByKey(noun) {
|
|
|
206
209
|
}
|
|
207
210
|
return reservations;
|
|
208
211
|
}
|
|
209
|
-
function
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
function effectsByAction(instrument, account, reservations, quotes) {
|
|
213
|
+
const quoteByCommit = new Map(quotes.map((quote) => [quote.commit, quote]));
|
|
214
|
+
const chargePaths = new Map(quotes.map((quote) => [`refs.${quote.chargeRef}`, quote]));
|
|
215
|
+
return new Map(Object.entries(instrument.actions).map(([actionName, action]) => [
|
|
216
|
+
actionName,
|
|
217
|
+
(action.moves ?? []).flatMap((step, stepIndex) => {
|
|
218
|
+
const path = [
|
|
219
|
+
"actions",
|
|
220
|
+
actionName,
|
|
221
|
+
"moves",
|
|
222
|
+
stepIndex,
|
|
223
|
+
"bind",
|
|
224
|
+
];
|
|
214
225
|
if (step.operation === "internal_transfer.create") {
|
|
215
226
|
const effects = [];
|
|
216
227
|
const amount = amountIdentity(step.bind.amount);
|
|
217
|
-
if (canonicalAccount(
|
|
228
|
+
if (canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
229
|
+
account) {
|
|
218
230
|
effects.push({ amount, kind: "credit", path });
|
|
219
231
|
}
|
|
220
|
-
const source = canonicalAccount(
|
|
232
|
+
const source = canonicalAccount(instrument, step.bind.sourceAccountId);
|
|
221
233
|
if (source && accountsMayAlias(source, account)) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
step.bind.amount.path
|
|
234
|
+
const committed = quoteByCommit.get(actionName);
|
|
235
|
+
const amountPath = step.bind.amount?.from === "instance"
|
|
236
|
+
? step.bind.amount.path
|
|
237
|
+
: undefined;
|
|
238
|
+
const refunded = committed && amountPath === `refs.${committed.netRef}`
|
|
239
|
+
? committed
|
|
240
|
+
: undefined;
|
|
241
|
+
const charged = amountPath
|
|
242
|
+
? chargePaths.get(amountPath)
|
|
243
|
+
: undefined;
|
|
244
|
+
const kind = refunded
|
|
225
245
|
? "refund"
|
|
226
|
-
:
|
|
227
|
-
step.bind.amount.path === "refs.unwindPenalty"
|
|
246
|
+
: charged
|
|
228
247
|
? "penalty_debit"
|
|
229
|
-
:
|
|
248
|
+
: amountPath !== undefined
|
|
230
249
|
? "debit"
|
|
231
250
|
: "dynamic_debit";
|
|
232
251
|
effects.push({
|
|
233
|
-
amount:
|
|
252
|
+
amount: refunded && !refunded.chargeCanBeNonzero
|
|
234
253
|
? "refund_without_penalty"
|
|
235
254
|
: amount,
|
|
236
255
|
kind,
|
|
237
256
|
path,
|
|
257
|
+
...(refunded ? { refundable: refunded.baseField } : {}),
|
|
238
258
|
});
|
|
239
259
|
}
|
|
240
260
|
return effects;
|
|
241
261
|
}
|
|
242
262
|
if (step.operation === "internal_transfer.reserve") {
|
|
243
|
-
const reservation = transferRefKey(step) ?? `${
|
|
263
|
+
const reservation = transferRefKey(step) ?? `${actionName}:${stepIndex}`;
|
|
244
264
|
const effects = [];
|
|
245
|
-
const source = canonicalAccount(
|
|
265
|
+
const source = canonicalAccount(instrument, step.bind.sourceAccountId);
|
|
246
266
|
if (source && accountsMayAlias(source, account)) {
|
|
247
267
|
effects.push({
|
|
248
268
|
amount: amountIdentity(step.bind.amount),
|
|
@@ -251,7 +271,8 @@ function effectsByVerb(noun, account, reservations, penaltyCanBeNonzero) {
|
|
|
251
271
|
reservation,
|
|
252
272
|
});
|
|
253
273
|
}
|
|
254
|
-
if (canonicalAccount(
|
|
274
|
+
if (canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
275
|
+
account) {
|
|
255
276
|
effects.push({
|
|
256
277
|
amount: amountIdentity(step.bind.amount),
|
|
257
278
|
kind: "incoming_reserve",
|
|
@@ -285,16 +306,16 @@ function effectsByVerb(noun, account, reservations, penaltyCanBeNonzero) {
|
|
|
285
306
|
}),
|
|
286
307
|
]));
|
|
287
308
|
}
|
|
288
|
-
function applyEffects(
|
|
309
|
+
function applyEffects(account, action, input, effects, reservations, exactBalance, add) {
|
|
289
310
|
let balance = input.balance;
|
|
290
311
|
const holds = { ...input.holds };
|
|
291
312
|
const accountLabel = formatAccount(account);
|
|
292
313
|
for (const effect of effects) {
|
|
293
|
-
const fail = (message) => add([...effect.path, "sourceAccountId"], `
|
|
314
|
+
const fail = (message) => add([...effect.path, "sourceAccountId"], `action ${action} ${message}`);
|
|
294
315
|
if (effect.kind === "credit") {
|
|
295
316
|
if (exactBalance &&
|
|
296
317
|
(balance.kind !== "empty" || Object.keys(holds).length > 0)) {
|
|
297
|
-
add([...effect.path, "destinationAccountId"], `
|
|
318
|
+
add([...effect.path, "destinationAccountId"], `action ${action} funds ${accountLabel} while earlier value may remain; quoted funding must establish exactly one refundable balance`);
|
|
298
319
|
balance = UNKNOWN;
|
|
299
320
|
continue;
|
|
300
321
|
}
|
|
@@ -340,14 +361,14 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
340
361
|
balance = held;
|
|
341
362
|
}
|
|
342
363
|
else {
|
|
343
|
-
add([...effect.path, "transferId"], `
|
|
364
|
+
add([...effect.path, "transferId"], `action ${action} restores a held ${accountLabel} balance on top of existing value`);
|
|
344
365
|
balance = UNKNOWN;
|
|
345
366
|
}
|
|
346
367
|
}
|
|
347
368
|
}
|
|
348
369
|
if (effect.kind === "post" && reservation?.destination === account) {
|
|
349
370
|
if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
|
|
350
|
-
add([...effect.path, "transferId"], `
|
|
371
|
+
add([...effect.path, "transferId"], `action ${action} posts funding into ${accountLabel} while earlier value may remain`);
|
|
351
372
|
balance = UNKNOWN;
|
|
352
373
|
}
|
|
353
374
|
else {
|
|
@@ -357,7 +378,7 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
357
378
|
continue;
|
|
358
379
|
}
|
|
359
380
|
if (effect.kind === "refund") {
|
|
360
|
-
const refundable = `fields.${
|
|
381
|
+
const refundable = `fields.${effect.refundable ?? ""}`;
|
|
361
382
|
if (balance.kind !== "funded" ||
|
|
362
383
|
balance.amounts.length !== 1 ||
|
|
363
384
|
balance.amounts[0] !== refundable) {
|
|
@@ -374,7 +395,7 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
374
395
|
}
|
|
375
396
|
if (effect.kind === "penalty_debit") {
|
|
376
397
|
if (balance.kind !== "penalty") {
|
|
377
|
-
fail(`cannot pay
|
|
398
|
+
fail(`cannot pay the quoted charge from ${accountLabel} before the refund leaves that exact remainder`);
|
|
378
399
|
}
|
|
379
400
|
balance = EMPTY;
|
|
380
401
|
continue;
|
|
@@ -384,7 +405,7 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
384
405
|
fail(`can debit unfunded ${accountLabel}`);
|
|
385
406
|
}
|
|
386
407
|
else if (balance.kind === "penalty") {
|
|
387
|
-
fail(`can debit ${accountLabel} after its refund left only
|
|
408
|
+
fail(`can debit ${accountLabel} after its refund left only the quoted charge`);
|
|
388
409
|
}
|
|
389
410
|
balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
|
|
390
411
|
continue;
|
|
@@ -397,7 +418,7 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
397
418
|
fail(`can debit unfunded ${accountLabel}`);
|
|
398
419
|
}
|
|
399
420
|
else if (balance.kind === "penalty") {
|
|
400
|
-
fail(`can debit ${accountLabel} after its refund left only
|
|
421
|
+
fail(`can debit ${accountLabel} after its refund left only the quoted charge`);
|
|
401
422
|
}
|
|
402
423
|
else if (balance.kind === "funded" || exactBalance) {
|
|
403
424
|
fail(`cannot prove ${effect.amount} is the exact available balance of ${accountLabel}`);
|
|
@@ -406,46 +427,81 @@ function applyEffects(noun, account, verb, input, effects, reservations, exactBa
|
|
|
406
427
|
}
|
|
407
428
|
return { balance, holds };
|
|
408
429
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Flattens every quote-commit pair the instrument declares. A quoting action
|
|
432
|
+
* with no committing action, or a commit naming a non-quoting action, is a
|
|
433
|
+
* validator failure rather than a typestate one, so it is simply skipped here.
|
|
434
|
+
*/
|
|
435
|
+
function quoteFacts(instrument, options) {
|
|
436
|
+
const facts = [];
|
|
437
|
+
for (const [quoting, action] of Object.entries(instrument.actions)) {
|
|
438
|
+
const quote = action.quote;
|
|
439
|
+
if (!quote)
|
|
440
|
+
continue;
|
|
441
|
+
const commit = Object.entries(instrument.actions).find(([, candidate]) => candidate.commit === quoting)?.[0];
|
|
442
|
+
if (commit === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
const chargePath = `refs.${quote.chargeRef}`;
|
|
445
|
+
const declaresChargePayout = Object.values(instrument.actions).some((candidate) => (candidate.moves ?? []).some((move) => Object.values(move.bind).some((binding) => binding.from === "instance" && binding.path === chargePath)));
|
|
446
|
+
facts.push({
|
|
447
|
+
baseField: quote.baseField,
|
|
448
|
+
chargeCanBeNonzero: options.penaltyMayBeNonzero === true ||
|
|
449
|
+
declaresChargePayout ||
|
|
450
|
+
quote.charges.some((tier) => tier.bps > 0),
|
|
451
|
+
chargeRef: quote.chargeRef,
|
|
452
|
+
commit,
|
|
453
|
+
netRef: quote.netRef,
|
|
454
|
+
quoting,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return facts;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The quoted charge is the exact remainder the commit leaves behind, so it may
|
|
461
|
+
* fund one transfer, out of the same account the refund left, in its own
|
|
462
|
+
* one-way action directly after the commit.
|
|
463
|
+
*/
|
|
464
|
+
function validateChargePayout(instrument, quote, refundSource, add) {
|
|
465
|
+
const chargePath = `refs.${quote.chargeRef}`;
|
|
466
|
+
const uses = Object.entries(instrument.actions).flatMap(([actionName, action]) => (action.moves ?? []).flatMap((step, stepIndex) => Object.entries(step.bind).flatMap(([target, binding]) => binding.from === "instance" && binding.path === chargePath
|
|
467
|
+
? [{ binding, step, stepIndex, target, action, actionName }]
|
|
412
468
|
: [])));
|
|
413
|
-
if (
|
|
414
|
-
add(["
|
|
469
|
+
if (quote.chargeCanBeNonzero && uses.length !== 1) {
|
|
470
|
+
add(["actions"], `${chargePath} is consumed ${uses.length} times; a nonzero charge schedule requires exactly one payout`);
|
|
415
471
|
}
|
|
416
472
|
if (uses.length === 0)
|
|
417
473
|
return;
|
|
418
474
|
if (uses.length !== 1) {
|
|
419
|
-
if (!
|
|
420
|
-
add(["
|
|
475
|
+
if (!quote.chargeCanBeNonzero) {
|
|
476
|
+
add(["actions"], `${chargePath} is consumed ${uses.length} times; it may fund at most one payout`);
|
|
421
477
|
}
|
|
422
478
|
return;
|
|
423
479
|
}
|
|
424
480
|
const use = uses[0];
|
|
425
|
-
const payoutTransition =
|
|
426
|
-
const
|
|
427
|
-
const source = canonicalAccount(
|
|
428
|
-
const destination = canonicalAccount(
|
|
429
|
-
const
|
|
481
|
+
const payoutTransition = instrument.lifecycle.transitions[use.actionName];
|
|
482
|
+
const commitTarget = instrument.lifecycle.transitions[quote.commit]?.to;
|
|
483
|
+
const source = canonicalAccount(instrument, use.step.bind.sourceAccountId);
|
|
484
|
+
const destination = canonicalAccount(instrument, use.step.bind.destinationAccountId);
|
|
485
|
+
const directAfterCommit = commitTarget !== undefined &&
|
|
430
486
|
payoutTransition?.from.length === 1 &&
|
|
431
|
-
payoutTransition.from[0] ===
|
|
487
|
+
payoutTransition.from[0] === commitTarget;
|
|
432
488
|
const oneWay = payoutTransition !== undefined &&
|
|
433
|
-
payoutTransition.to !==
|
|
434
|
-
!stateCanReach(
|
|
489
|
+
payoutTransition.to !== commitTarget &&
|
|
490
|
+
!stateCanReach(instrument, payoutTransition.to, commitTarget);
|
|
435
491
|
const valid = use.target === "amount" &&
|
|
436
492
|
use.step.operation === "internal_transfer.create" &&
|
|
437
|
-
(use.
|
|
438
|
-
|
|
493
|
+
(use.action.moves?.length ?? 0) === 1 &&
|
|
494
|
+
directAfterCommit &&
|
|
439
495
|
oneWay &&
|
|
440
496
|
source !== undefined &&
|
|
441
497
|
source === refundSource &&
|
|
442
498
|
destination !== undefined &&
|
|
443
499
|
!accountsMayAlias(source, destination);
|
|
444
500
|
if (!valid) {
|
|
445
|
-
add(["
|
|
501
|
+
add(["actions", use.actionName, "moves", use.stepIndex, "bind", use.target], `${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}`);
|
|
446
502
|
}
|
|
447
503
|
}
|
|
448
|
-
function canonicalAccount(
|
|
504
|
+
function canonicalAccount(instrument, binding) {
|
|
449
505
|
if (binding?.from !== "instance")
|
|
450
506
|
return undefined;
|
|
451
507
|
const [root, key, extra] = binding.path.split(".");
|
|
@@ -456,7 +512,7 @@ function canonicalAccount(noun, binding) {
|
|
|
456
512
|
if (root === "refs")
|
|
457
513
|
return `ref:${key}`;
|
|
458
514
|
if (root === "party") {
|
|
459
|
-
const field =
|
|
515
|
+
const field = instrument.parties?.[key];
|
|
460
516
|
return field ? `field:${field}` : undefined;
|
|
461
517
|
}
|
|
462
518
|
return undefined;
|
|
@@ -480,7 +536,7 @@ function boundTransferRefKey(step) {
|
|
|
480
536
|
? binding.path.slice("refs.".length)
|
|
481
537
|
: undefined;
|
|
482
538
|
}
|
|
483
|
-
function stateCanReach(
|
|
539
|
+
function stateCanReach(instrument, start, target) {
|
|
484
540
|
if (!target)
|
|
485
541
|
return false;
|
|
486
542
|
const seen = new Set([start]);
|
|
@@ -489,7 +545,7 @@ function stateCanReach(noun, start, target) {
|
|
|
489
545
|
const state = pending.shift();
|
|
490
546
|
if (state === target)
|
|
491
547
|
return true;
|
|
492
|
-
for (const transition of Object.values(
|
|
548
|
+
for (const transition of Object.values(instrument.lifecycle.transitions)) {
|
|
493
549
|
if (!transition.from.includes(state) || seen.has(transition.to))
|
|
494
550
|
continue;
|
|
495
551
|
seen.add(transition.to);
|