@hyperscale0/udl 2.6.0 → 3.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 +2 -322
- package/LICENSING.md +1 -2
- package/README.md +3 -114
- package/TRADEMARKS.md +2 -2
- package/dist/diagnostics.d.ts +21 -201
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +29 -193
- package/dist/diagnostics.js.map +1 -1
- package/dist/evolution.d.ts +3 -132
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +29 -633
- package/dist/evolution.js.map +1 -1
- package/dist/finance.d.ts +5 -72
- package/dist/finance.d.ts.map +1 -1
- package/dist/finance.js +233 -737
- package/dist/finance.js.map +1 -1
- package/dist/index.d.ts +7 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -12
- package/dist/index.js.map +1 -1
- package/dist/instrument-references.d.ts.map +1 -1
- package/dist/instrument-references.js +3 -2
- package/dist/instrument-references.js.map +1 -1
- package/dist/schema.d.ts +2079 -4564
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +270 -1609
- package/dist/schema.js.map +1 -1
- package/dist/validation.d.ts +8 -104
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +653 -3271
- package/dist/validation.js.map +1 -1
- package/docs/README.md +147 -14
- package/package.json +7 -11
- package/spec/README.md +151 -164
- package/spec/darb.udl.json +249 -0
- package/spec/udl.schema.json +4104 -3202
- package/src/diagnostics.ts +51 -246
- package/src/evolution.ts +39 -1045
- package/src/finance.ts +287 -1127
- package/src/index.ts +18 -142
- package/src/instrument-references.ts +3 -2
- package/src/schema.ts +285 -1843
- package/src/validation.ts +870 -5611
- 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/call-binds-results.expected.json +0 -10
- package/conformance/invalid/call-binds-results.udl +0 -314
- package/conformance/invalid/call-unknown-action.expected.json +0 -10
- package/conformance/invalid/call-unknown-action.udl +0 -314
- 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-journeys.expected.json +0 -10
- package/conformance/invalid/invalid-journeys.udl +0 -77
- 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/leaf-effect-mismatch.expected.json +0 -10
- package/conformance/invalid/leaf-effect-mismatch.udl +0 -314
- 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/piece-plan-without-partition.expected.json +0 -10
- package/conformance/invalid/piece-plan-without-partition.udl +0 -305
- package/conformance/invalid/private-action-independent-approval.expected.json +0 -10
- package/conformance/invalid/private-action-independent-approval.udl +0 -314
- 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/unfund-order-not-reversed.expected.json +0 -10
- package/conformance/invalid/unfund-order-not-reversed.udl +0 -314
- 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/attested.expected.json +0 -6
- package/conformance/valid/attested.udl +0 -251
- 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 -62
- package/conformance/valid/piece-plan-calls.expected.json +0 -6
- package/conformance/valid/piece-plan-calls.udl +0 -314
- 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/conformance/valid/vocabulary.expected.json +0 -6
- package/conformance/valid/vocabulary.udl +0 -2012
- package/dist/allocation.d.ts +0 -60
- package/dist/allocation.d.ts.map +0 -1
- package/dist/allocation.js +0 -177
- package/dist/allocation.js.map +0 -1
- 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/distribution.d.ts +0 -15
- package/dist/distribution.d.ts.map +0 -1
- package/dist/distribution.js +0 -49
- package/dist/distribution.js.map +0 -1
- package/dist/effects.d.ts +0 -58
- package/dist/effects.d.ts.map +0 -1
- package/dist/effects.js +0 -1126
- package/dist/effects.js.map +0 -1
- package/dist/reference.d.ts +0 -3
- package/dist/reference.d.ts.map +0 -1
- package/dist/reference.js +0 -28
- package/dist/reference.js.map +0 -1
- package/dist/vocabulary.d.ts +0 -23
- package/dist/vocabulary.d.ts.map +0 -1
- package/dist/vocabulary.js +0 -1052
- package/dist/vocabulary.js.map +0 -1
- package/docs/funding-custody.md +0 -165
- 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 -36
- 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/guide/09-schedules-and-allocation.md +0 -132
- package/docs/llms-full.txt +0 -2008
- package/docs/llms.txt +0 -14
- package/docs/piece-plans.md +0 -148
- package/docs/reference/canonical.md +0 -16
- package/docs/reference/clauses.md +0 -1585
- package/docs/reference/cli.md +0 -24
- package/docs/reference/diagnostics.md +0 -38
- package/skills/udl/SKILL.md +0 -28
- package/src/allocation.ts +0 -259
- package/src/check-profiles.ts +0 -80
- package/src/distribution.ts +0 -61
- package/src/effects.ts +0 -1920
- package/src/reference.ts +0 -31
- package/src/vocabulary.ts +0 -1635
package/src/finance.ts
CHANGED
|
@@ -1,1164 +1,324 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface FinancialContribution {
|
|
31
|
-
readonly field: string;
|
|
32
|
-
readonly amountKey: string;
|
|
33
|
-
readonly accountKey: string;
|
|
34
|
-
readonly totalField: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface FinancialContributionStage {
|
|
38
|
-
readonly stage: "fund" | "refund";
|
|
39
|
-
readonly accountPath: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface FinancialInstrument {
|
|
43
|
-
readonly lifecycle: {
|
|
44
|
-
readonly initial: string;
|
|
45
|
-
readonly states: readonly string[];
|
|
46
|
-
readonly transitions: Readonly<
|
|
47
|
-
Record<string, { readonly from: readonly string[]; readonly to: string }>
|
|
48
|
-
>;
|
|
49
|
-
};
|
|
50
|
-
readonly parties?:
|
|
51
|
-
| {
|
|
52
|
-
readonly beneficiary?: string | undefined;
|
|
53
|
-
readonly payer?: string | undefined;
|
|
54
|
-
readonly subjectHolder?: string | undefined;
|
|
55
|
-
}
|
|
56
|
-
| undefined;
|
|
57
|
-
readonly partitions?: readonly FinancialPartition[] | undefined;
|
|
58
|
-
readonly contributions?: FinancialContribution | undefined;
|
|
59
|
-
readonly actions: Readonly<
|
|
60
|
-
Record<
|
|
61
|
-
string,
|
|
62
|
-
{
|
|
63
|
-
readonly commit?: string | undefined;
|
|
64
|
-
readonly moves?: readonly FinancialMove[];
|
|
65
|
-
readonly quote?:
|
|
66
|
-
| {
|
|
67
|
-
readonly baseField: string;
|
|
68
|
-
readonly chargeRef: string;
|
|
69
|
-
readonly chargeRetainedBy?:
|
|
70
|
-
| "payer"
|
|
71
|
-
| "beneficiary"
|
|
72
|
-
| "subjectHolder"
|
|
73
|
-
| undefined;
|
|
74
|
-
readonly charges: readonly { readonly bps: number }[];
|
|
75
|
-
readonly netRef: string;
|
|
76
|
-
}
|
|
77
|
-
| undefined;
|
|
78
|
-
readonly steps: readonly FinancialStep[];
|
|
79
|
-
readonly contributionStage?: FinancialContributionStage | undefined;
|
|
80
|
-
}
|
|
81
|
-
>
|
|
82
|
-
>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* One quote-commit pair, flattened for the typestate. The quoting action prices
|
|
87
|
-
* `baseField` into the charge and the net; the committing action spends the net
|
|
88
|
-
* out of the account that funded the base.
|
|
89
|
-
*/
|
|
90
|
-
interface QuoteFacts {
|
|
91
|
-
readonly baseField: string;
|
|
92
|
-
readonly chargeRef: string;
|
|
93
|
-
readonly chargeRetainedBy?:
|
|
94
|
-
| "payer"
|
|
95
|
-
| "beneficiary"
|
|
96
|
-
| "subjectHolder"
|
|
97
|
-
| undefined;
|
|
98
|
-
readonly chargeCanBeNonzero: boolean;
|
|
99
|
-
readonly commit: string;
|
|
100
|
-
readonly netRef: string;
|
|
101
|
-
readonly quoting: string;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
type Balance =
|
|
105
|
-
| { readonly kind: "empty" }
|
|
106
|
-
| { readonly kind: "funded"; readonly amounts: readonly string[] }
|
|
107
|
-
| { readonly kind: "penalty" }
|
|
108
|
-
| { readonly kind: "unknown" };
|
|
109
|
-
|
|
110
|
-
interface AccountState {
|
|
111
|
-
readonly balance: Balance;
|
|
112
|
-
readonly holds: Readonly<Record<string, Balance>>;
|
|
113
|
-
readonly exchanged?: boolean | undefined;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
interface Reservation {
|
|
117
|
-
readonly amount: string;
|
|
118
|
-
readonly destination?: string;
|
|
119
|
-
readonly source?: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
interface Effect {
|
|
123
|
-
readonly amount: string;
|
|
124
|
-
/** The quoted base a refund effect returns; absent on every other kind. */
|
|
125
|
-
readonly refundable?: string;
|
|
126
|
-
readonly kind:
|
|
127
|
-
| "credit"
|
|
128
|
-
| "debit"
|
|
129
|
-
| "dynamic_debit"
|
|
130
|
-
| "incoming_reserve"
|
|
131
|
-
| "outgoing_reserve"
|
|
132
|
-
| "penalty_debit"
|
|
133
|
-
| "post"
|
|
134
|
-
| "refund"
|
|
135
|
-
| "void";
|
|
136
|
-
readonly path: readonly PropertyKey[];
|
|
137
|
-
readonly reservation?: string;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const EMPTY: Balance = { kind: "empty" };
|
|
141
|
-
|
|
142
|
-
const UNKNOWN: Balance = { kind: "unknown" };
|
|
143
|
-
|
|
144
|
-
// A funded balance is the exact set of amount identities currently held.
|
|
145
|
-
// Distinct identities stack (a partitioned funding); re-crediting an identity
|
|
146
|
-
// already present degrades to unknown, which keeps the state space finite.
|
|
147
|
-
function funded(amounts: readonly string[]): Balance {
|
|
148
|
-
if (amounts.length === 0) return EMPTY;
|
|
149
|
-
return { kind: "funded", amounts: [...amounts].sort() };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function withoutPiece(
|
|
153
|
-
amounts: readonly string[],
|
|
154
|
-
amount: string,
|
|
155
|
-
): readonly string[] {
|
|
156
|
-
const index = amounts.indexOf(amount);
|
|
157
|
-
return amounts.filter((_, position) => position !== index);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* One financial typestate owner for canonical UDL and static instrument contracts.
|
|
162
|
-
* It proves a tracked escrow account holds an exact set of funded amounts,
|
|
163
|
-
* models held transfers through reserve/post/void, consumes every direct
|
|
164
|
-
* debit piece by piece, and makes the quoted refund's sole remainder an
|
|
165
|
-
* exactly-once penalty payout.
|
|
166
|
-
*/
|
|
167
|
-
export function analyzeInstrumentFinance(
|
|
168
|
-
instrument: FinancialInstrument,
|
|
169
|
-
options: FinanceOptions = {},
|
|
170
|
-
): readonly FinanceIssue[] {
|
|
171
|
-
const issues: FinanceIssue[] = [];
|
|
172
|
-
const seenIssues = new Set<string>();
|
|
173
|
-
const add = (
|
|
174
|
-
path: readonly PropertyKey[],
|
|
175
|
-
message: string,
|
|
176
|
-
code: UdlIssueCode = "UDL4001",
|
|
177
|
-
): void => {
|
|
178
|
-
const key = `${path.join(".")}\0${message}`;
|
|
179
|
-
if (seenIssues.has(key)) return;
|
|
180
|
-
seenIssues.add(key);
|
|
181
|
-
issues.push({ code, message, path });
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const admissionProblem = financeAdmissionProblem(instrument);
|
|
185
|
-
if (admissionProblem) {
|
|
186
|
-
add(["lifecycle"], admissionProblem, "UDL1004");
|
|
187
|
-
return issues;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const trackedAccounts = productEscrowAccounts(instrument);
|
|
191
|
-
const quotes = quoteFacts(instrument, options);
|
|
192
|
-
const refundSources = new Map<string, string>();
|
|
193
|
-
for (const quote of quotes) {
|
|
194
|
-
const commitTransfer = instrument.actions[quote.commit]?.moves?.filter(
|
|
195
|
-
(move) => move.operation === "internal_transfer.create",
|
|
196
|
-
)[0];
|
|
197
|
-
const source = canonicalAccount(
|
|
198
|
-
instrument,
|
|
199
|
-
commitTransfer?.bind.sourceAccountId,
|
|
200
|
-
);
|
|
201
|
-
if (!source) continue;
|
|
202
|
-
refundSources.set(quote.commit, source);
|
|
203
|
-
trackedAccounts.add(source);
|
|
204
|
-
}
|
|
205
|
-
const refundSourceAccounts = new Set(refundSources.values());
|
|
206
|
-
for (const quote of quotes) {
|
|
207
|
-
validateChargePayout(
|
|
208
|
-
instrument,
|
|
209
|
-
quote,
|
|
210
|
-
refundSources.get(quote.commit),
|
|
211
|
-
add,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
if (trackedAccounts.size === 0) return issues;
|
|
215
|
-
if (trackedAccounts.size > UDL_LIMITS.financeAccounts) {
|
|
216
|
-
add(
|
|
217
|
-
["actions"],
|
|
218
|
-
`financial analysis exceeds ${UDL_LIMITS.financeAccounts} tracked accounts`,
|
|
219
|
-
"UDL1004",
|
|
220
|
-
);
|
|
221
|
-
return issues;
|
|
9
|
+
path: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
type Sum = Map<string, bigint>;
|
|
13
|
+
type Balance = Sum | null;
|
|
14
|
+
interface Hold {
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
amount: Balance;
|
|
18
|
+
}
|
|
19
|
+
interface State {
|
|
20
|
+
status: string;
|
|
21
|
+
balances: Map<string, Balance>;
|
|
22
|
+
holds: Map<string, Hold>;
|
|
23
|
+
}
|
|
24
|
+
const add = (a: Balance, b: Balance, sign = 1n): Balance => {
|
|
25
|
+
if (a === null || b === null) return null;
|
|
26
|
+
const result = new Map(a);
|
|
27
|
+
for (const [key, value] of b) {
|
|
28
|
+
const next = (result.get(key) ?? 0n) + value * sign;
|
|
29
|
+
if (next) result.set(key, next);
|
|
30
|
+
else result.delete(key);
|
|
222
31
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (work > UDL_LIMITS.financeWork) {
|
|
233
|
-
add(
|
|
234
|
-
["lifecycle"],
|
|
235
|
-
`financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
|
|
236
|
-
"UDL1004",
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const show = (sum: Balance): string =>
|
|
35
|
+
sum === null
|
|
36
|
+
? "unknown"
|
|
37
|
+
: JSON.stringify(
|
|
38
|
+
[...sum]
|
|
39
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
40
|
+
.map(([key, value]) => [key, String(value)]),
|
|
237
41
|
);
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
const initial = applyEffects(
|
|
241
|
-
instrument,
|
|
242
|
-
account,
|
|
243
|
-
"create",
|
|
244
|
-
{ balance: EMPTY, holds: {} },
|
|
245
|
-
createEffects,
|
|
246
|
-
reservations,
|
|
247
|
-
refundSourceAccounts.has(account),
|
|
248
|
-
add,
|
|
249
|
-
);
|
|
250
|
-
const states = new Map<string, Map<string, AccountState>>([
|
|
251
|
-
[instrument.lifecycle.initial, new Map([[stateKey(initial), initial]])],
|
|
252
|
-
]);
|
|
253
|
-
pathVariants += 1;
|
|
254
|
-
const pending = [instrument.lifecycle.initial];
|
|
255
|
-
while (pending.length > 0) {
|
|
256
|
-
const from = pending.shift() as string;
|
|
257
|
-
const sourceStates = states.get(from);
|
|
258
|
-
if (!sourceStates) continue;
|
|
259
|
-
for (const [action, transition] of Object.entries(
|
|
260
|
-
instrument.lifecycle.transitions,
|
|
261
|
-
)) {
|
|
262
|
-
if (!transition.from.includes(from)) continue;
|
|
263
|
-
const targetStates = states.get(transition.to) ?? new Map();
|
|
264
|
-
states.set(transition.to, targetStates);
|
|
265
|
-
let grew = false;
|
|
266
|
-
for (const sourceState of sourceStates.values()) {
|
|
267
|
-
const transitionEffects = effects.get(action) ?? [];
|
|
268
|
-
work += 1 + transitionEffects.length;
|
|
269
|
-
if (work > UDL_LIMITS.financeWork) {
|
|
270
|
-
add(
|
|
271
|
-
["lifecycle"],
|
|
272
|
-
`financial analysis exceeds ${UDL_LIMITS.financeWork} deterministic work units`,
|
|
273
|
-
"UDL1004",
|
|
274
|
-
);
|
|
275
|
-
return issues;
|
|
276
|
-
}
|
|
277
|
-
const targetState = applyEffects(
|
|
278
|
-
instrument,
|
|
279
|
-
account,
|
|
280
|
-
action,
|
|
281
|
-
sourceState,
|
|
282
|
-
transitionEffects,
|
|
283
|
-
reservations,
|
|
284
|
-
refundSourceAccounts.has(account),
|
|
285
|
-
add,
|
|
286
|
-
);
|
|
287
|
-
const key = stateKey(targetState);
|
|
288
|
-
if (targetStates.has(key)) continue;
|
|
289
|
-
targetStates.set(key, targetState);
|
|
290
|
-
pathVariants += 1;
|
|
291
|
-
if (pathVariants > UDL_LIMITS.financePathVariants) {
|
|
292
|
-
add(
|
|
293
|
-
["lifecycle"],
|
|
294
|
-
`financial analysis exceeds ${UDL_LIMITS.financePathVariants} distinct path variants`,
|
|
295
|
-
"UDL1004",
|
|
296
|
-
);
|
|
297
|
-
return issues;
|
|
298
|
-
}
|
|
299
|
-
grew = true;
|
|
300
|
-
}
|
|
301
|
-
if (grew && !pending.includes(transition.to))
|
|
302
|
-
pending.push(transition.to);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
const nonterminalStates = new Set(
|
|
306
|
-
Object.values(instrument.lifecycle.transitions).flatMap(
|
|
307
|
-
(transition) => transition.from,
|
|
308
|
-
),
|
|
309
|
-
);
|
|
310
|
-
if (account.startsWith("ref:")) {
|
|
311
|
-
for (const [state, variants] of states) {
|
|
312
|
-
if (nonterminalStates.has(state)) continue;
|
|
313
|
-
for (const variant of variants.values()) {
|
|
314
|
-
if (
|
|
315
|
-
variant.balance.kind === "empty" &&
|
|
316
|
-
Object.keys(variant.holds).length === 0
|
|
317
|
-
) {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
add(
|
|
321
|
-
["lifecycle", "states", instrument.lifecycle.states.indexOf(state)],
|
|
322
|
-
`terminal state ${state} can strand value in ${formatAccount(account)}`,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return issues;
|
|
330
|
-
}
|
|
42
|
+
const same = (a: Balance, b: Balance) => show(a) === show(b);
|
|
331
43
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
44
|
+
/** Join loop balances conservatively; a balance equality proves an unknown balance empty. */
|
|
45
|
+
export function analyzeInstrumentFinance(
|
|
46
|
+
instrument: UdlInstrument,
|
|
47
|
+
document?: UdlDocument,
|
|
48
|
+
): FinanceIssue[] {
|
|
49
|
+
const owned = new Set(
|
|
50
|
+
instrument.fields
|
|
51
|
+
.filter((f) => f.type === "account" && f.owner === "self")
|
|
52
|
+
.map((f) => `self.${f.name}`),
|
|
53
|
+
);
|
|
54
|
+
const shared = new Set<string>();
|
|
55
|
+
for (const owner of document?.instruments ?? []) {
|
|
56
|
+
if (owner.id === instrument.id) continue;
|
|
57
|
+
for (const action of Object.values(owner.actions))
|
|
58
|
+
for (const move of action.moves)
|
|
59
|
+
if ("amount" in move)
|
|
60
|
+
for (const endpoint of [move.from, move.to]) {
|
|
61
|
+
const parts = endpoint.split(".");
|
|
62
|
+
if (parts.shift() !== "self") continue;
|
|
63
|
+
let targets = [owner];
|
|
64
|
+
while (parts.length > 1) {
|
|
65
|
+
const key = parts.shift();
|
|
66
|
+
targets = targets.flatMap((target) => {
|
|
67
|
+
const reference = target.fields.find(
|
|
68
|
+
(field) => field.name === key,
|
|
69
|
+
);
|
|
70
|
+
if (reference?.type !== "ref") return [];
|
|
71
|
+
const ids =
|
|
72
|
+
typeof reference.target === "string"
|
|
73
|
+
? [reference.target]
|
|
74
|
+
: reference.target;
|
|
75
|
+
return (
|
|
76
|
+
document?.instruments.filter((candidate) =>
|
|
77
|
+
ids.includes(candidate.id),
|
|
78
|
+
) ?? []
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
parts.length === 1 &&
|
|
84
|
+
targets.some((target) => target.id === instrument.id) &&
|
|
85
|
+
owned.has(`self.${parts[0]}`)
|
|
86
|
+
)
|
|
87
|
+
shared.add(`self.${parts[0]}`);
|
|
88
|
+
}
|
|
350
89
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
90
|
+
const problems: FinanceIssue[] = [];
|
|
91
|
+
const report = (message: string) =>
|
|
92
|
+
problems.push({ path: ".actions", message });
|
|
93
|
+
const equations = new Map<string, Sum>();
|
|
94
|
+
for (const field of instrument.fields)
|
|
95
|
+
if (field.type === "money" && field.value !== undefined)
|
|
96
|
+
equations.set(
|
|
97
|
+
`self.${field.name}`,
|
|
98
|
+
new Map([["#", BigInt(field.value)]]),
|
|
99
|
+
);
|
|
100
|
+
for (const calc of instrument.calculate) {
|
|
101
|
+
if (calc.op === "sum" || calc.op === "subtract") {
|
|
102
|
+
const terms =
|
|
103
|
+
calc.op === "sum"
|
|
104
|
+
? calc.values.map((v) => [v, 1n] as const)
|
|
105
|
+
: [
|
|
106
|
+
[calc.base, 1n] as const,
|
|
107
|
+
...calc.subtract.map((v) => [v, -1n] as const),
|
|
108
|
+
];
|
|
109
|
+
let result: Balance = new Map();
|
|
110
|
+
for (const [value, sign] of terms)
|
|
111
|
+
result = add(result, raw(value, "create"), sign);
|
|
112
|
+
equations.set(`self.${calc.target}`, result!);
|
|
363
113
|
}
|
|
364
114
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (result === "accountId") accounts.add(`ref:${key}`);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
115
|
+
function raw(value: UdlValue, invocation: string): Sum {
|
|
116
|
+
return "field" in value
|
|
117
|
+
? new Map([
|
|
118
|
+
[
|
|
119
|
+
value.field.startsWith("input.")
|
|
120
|
+
? `${invocation}:${value.field}`
|
|
121
|
+
: value.field,
|
|
122
|
+
1n,
|
|
123
|
+
],
|
|
124
|
+
])
|
|
125
|
+
: new Map([["#", BigInt(String(value.literal))]]);
|
|
379
126
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
instrument,
|
|
391
|
-
action.contributionStage.accountPath,
|
|
392
|
-
) === account
|
|
393
|
-
) {
|
|
394
|
-
return true;
|
|
395
|
-
}
|
|
396
|
-
return (action.moves ?? []).some((step) => {
|
|
397
|
-
if (
|
|
398
|
-
(step.operation !== "internal_transfer.create" &&
|
|
399
|
-
step.operation !== "internal_transfer.reserve") ||
|
|
400
|
-
step.bind.amount?.from !== "instance"
|
|
401
|
-
) {
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
return (
|
|
405
|
-
canonicalAccount(instrument, step.bind.sourceAccountId) ===
|
|
406
|
-
account ||
|
|
407
|
-
canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
408
|
-
account
|
|
409
|
-
);
|
|
410
|
-
});
|
|
411
|
-
}),
|
|
412
|
-
),
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function reservationsByKey(
|
|
417
|
-
instrument: FinancialInstrument,
|
|
418
|
-
): ReadonlyMap<string, Reservation> {
|
|
419
|
-
const reservations = new Map<string, Reservation>();
|
|
420
|
-
for (const action of Object.values(instrument.actions)) {
|
|
421
|
-
for (const step of action.moves ?? []) {
|
|
422
|
-
if (step.operation !== "internal_transfer.reserve") continue;
|
|
423
|
-
const source = canonicalAccount(instrument, step.bind.sourceAccountId);
|
|
424
|
-
const destination = canonicalAccount(
|
|
425
|
-
instrument,
|
|
426
|
-
step.bind.destinationAccountId,
|
|
127
|
+
function expand(sum: Sum, seen = new Set<string>()): Sum {
|
|
128
|
+
let result: Balance = new Map();
|
|
129
|
+
for (const [key, coefficient] of sum) {
|
|
130
|
+
const equation = equations.get(key);
|
|
131
|
+
result = add(
|
|
132
|
+
result,
|
|
133
|
+
equation && !seen.has(key)
|
|
134
|
+
? expand(equation, new Set([...seen, key]))
|
|
135
|
+
: new Map([[key, 1n]]),
|
|
136
|
+
coefficient,
|
|
427
137
|
);
|
|
428
|
-
const amount = amountIdentity(step.bind.amount);
|
|
429
|
-
for (const [key, result] of Object.entries(step.capture ?? {})) {
|
|
430
|
-
if (result === "transferId") {
|
|
431
|
-
reservations.set(key, {
|
|
432
|
-
amount,
|
|
433
|
-
...(destination ? { destination } : {}),
|
|
434
|
-
...(source ? { source } : {}),
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
138
|
}
|
|
139
|
+
return result!;
|
|
439
140
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const chargePaths = new Map(
|
|
451
|
-
quotes.map((quote) => [`refs.${quote.chargeRef}`, quote]),
|
|
452
|
-
);
|
|
453
|
-
return new Map(
|
|
454
|
-
Object.entries(instrument.actions).map(([actionName, action]) => {
|
|
455
|
-
const stageEffects: Effect[] = [];
|
|
456
|
-
if (action.contributionStage && instrument.contributions) {
|
|
457
|
-
const stage = action.contributionStage;
|
|
458
|
-
const stageAccount = canonicalAccountPath(
|
|
459
|
-
instrument,
|
|
460
|
-
stage.accountPath,
|
|
461
|
-
);
|
|
141
|
+
let invocation = 0;
|
|
142
|
+
const apply = (state: State, action: UdlAction, name: string): void => {
|
|
143
|
+
const scope = `${name}#${invocation++}`;
|
|
144
|
+
const calculated = new Map<string, Balance>();
|
|
145
|
+
const local = new Map(
|
|
146
|
+
(action.calculate ?? []).map((c) => [`self.${c.target}`, c]),
|
|
147
|
+
);
|
|
148
|
+
const amount = (value: UdlValue, seen = new Set<string>()): Balance => {
|
|
149
|
+
if ("field" in value) {
|
|
150
|
+
if (calculated.has(value.field)) return calculated.get(value.field)!;
|
|
462
151
|
if (
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
152
|
+
value.field.endsWith(".balance") &&
|
|
153
|
+
owned.has(value.field.slice(0, -8))
|
|
154
|
+
)
|
|
155
|
+
return state.balances.get(value.field.slice(0, -8)) ?? null;
|
|
156
|
+
const calculation = local.get(value.field);
|
|
157
|
+
if (calculation && !seen.has(value.field)) {
|
|
158
|
+
const next = new Set([...seen, value.field]);
|
|
159
|
+
if (calculation.op === "sum")
|
|
160
|
+
return calculation.values.reduce<Balance>(
|
|
161
|
+
(sum, value) => add(sum, amount(value, next)),
|
|
162
|
+
new Map(),
|
|
163
|
+
);
|
|
164
|
+
if (calculation.op === "subtract")
|
|
165
|
+
return calculation.subtract.reduce<Balance>(
|
|
166
|
+
(sum, value) => add(sum, amount(value, next), -1n),
|
|
167
|
+
amount(calculation.base, next),
|
|
168
|
+
);
|
|
169
|
+
return new Map([[`${scope}:${value.field}`, 1n]]);
|
|
481
170
|
}
|
|
482
171
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const amount = amountIdentity(step.bind.amount);
|
|
495
|
-
if (
|
|
496
|
-
canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
497
|
-
account
|
|
498
|
-
) {
|
|
499
|
-
effects.push({ amount, kind: "credit", path });
|
|
500
|
-
}
|
|
501
|
-
const source = canonicalAccount(
|
|
502
|
-
instrument,
|
|
503
|
-
step.bind.sourceAccountId,
|
|
504
|
-
);
|
|
505
|
-
if (source && accountsMayAlias(instrument, source, account)) {
|
|
506
|
-
const committed = quoteByCommit.get(actionName);
|
|
507
|
-
const amountPath =
|
|
508
|
-
step.bind.amount?.from === "instance"
|
|
509
|
-
? step.bind.amount.path
|
|
510
|
-
: undefined;
|
|
511
|
-
const refunded =
|
|
512
|
-
committed && amountPath === `refs.${committed.netRef}`
|
|
513
|
-
? committed
|
|
514
|
-
: undefined;
|
|
515
|
-
const charged = amountPath
|
|
516
|
-
? chargePaths.get(amountPath)
|
|
517
|
-
: undefined;
|
|
518
|
-
const kind = refunded
|
|
519
|
-
? "refund"
|
|
520
|
-
: charged
|
|
521
|
-
? "penalty_debit"
|
|
522
|
-
: amountPath !== undefined
|
|
523
|
-
? "debit"
|
|
524
|
-
: "dynamic_debit";
|
|
525
|
-
effects.push({
|
|
526
|
-
amount:
|
|
527
|
-
refunded && !refunded.chargeCanBeNonzero
|
|
528
|
-
? "refund_without_penalty"
|
|
529
|
-
: amount,
|
|
530
|
-
kind,
|
|
531
|
-
path,
|
|
532
|
-
...(refunded ? { refundable: refunded.baseField } : {}),
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
return effects;
|
|
536
|
-
}
|
|
537
|
-
if (step.operation === "internal_transfer.reserve") {
|
|
538
|
-
const reservation =
|
|
539
|
-
transferRefKey(step) ?? `${actionName}:${stepIndex}`;
|
|
540
|
-
const effects: Effect[] = [];
|
|
541
|
-
const source = canonicalAccount(
|
|
542
|
-
instrument,
|
|
543
|
-
step.bind.sourceAccountId,
|
|
544
|
-
);
|
|
545
|
-
if (source && accountsMayAlias(instrument, source, account)) {
|
|
546
|
-
effects.push({
|
|
547
|
-
amount: amountIdentity(step.bind.amount),
|
|
548
|
-
kind: "outgoing_reserve",
|
|
549
|
-
path,
|
|
550
|
-
reservation,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
if (
|
|
554
|
-
canonicalAccount(instrument, step.bind.destinationAccountId) ===
|
|
555
|
-
account
|
|
556
|
-
) {
|
|
557
|
-
effects.push({
|
|
558
|
-
amount: amountIdentity(step.bind.amount),
|
|
559
|
-
kind: "incoming_reserve",
|
|
560
|
-
path,
|
|
561
|
-
reservation,
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
return effects;
|
|
565
|
-
}
|
|
566
|
-
if (
|
|
567
|
-
step.operation === "internal_transfer.post" ||
|
|
568
|
-
step.operation === "internal_transfer.void"
|
|
569
|
-
) {
|
|
570
|
-
const reservation = boundTransferRefKey(step);
|
|
571
|
-
if (!reservation) return [];
|
|
572
|
-
const reserved = reservations.get(reservation);
|
|
573
|
-
if (
|
|
574
|
-
!reserved ||
|
|
575
|
-
(!(
|
|
576
|
-
reserved.source &&
|
|
577
|
-
accountsMayAlias(instrument, reserved.source, account)
|
|
578
|
-
) &&
|
|
579
|
-
reserved.destination !== account)
|
|
580
|
-
) {
|
|
581
|
-
return [];
|
|
172
|
+
return expand(raw(value, scope));
|
|
173
|
+
};
|
|
174
|
+
for (const requirement of action.requires)
|
|
175
|
+
if (requirement.kind === "compare" && requirement.operator === "==") {
|
|
176
|
+
for (const [left, right] of [
|
|
177
|
+
[requirement.left, requirement.right],
|
|
178
|
+
[requirement.right, requirement.left],
|
|
179
|
+
] as const) {
|
|
180
|
+
if ("field" in left && left.field.endsWith(".balance")) {
|
|
181
|
+
const account = left.field.slice(0, -8);
|
|
182
|
+
if (owned.has(account)) state.balances.set(account, amount(right));
|
|
582
183
|
}
|
|
583
|
-
return [
|
|
584
|
-
{
|
|
585
|
-
amount: reserved.amount,
|
|
586
|
-
kind:
|
|
587
|
-
step.operation === "internal_transfer.post" ? "post" : "void",
|
|
588
|
-
path,
|
|
589
|
-
reservation,
|
|
590
|
-
} satisfies Effect,
|
|
591
|
-
];
|
|
592
|
-
}
|
|
593
|
-
return [];
|
|
594
|
-
});
|
|
595
|
-
return [actionName, [...stageEffects, ...moveEffects]];
|
|
596
|
-
}),
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
interface NormalizedPartition {
|
|
601
|
-
readonly total: string;
|
|
602
|
-
readonly pieces: readonly string[];
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function getInstrumentPartitions(
|
|
606
|
-
instrument: FinancialInstrument,
|
|
607
|
-
): readonly NormalizedPartition[] {
|
|
608
|
-
if (!instrument.partitions) return [];
|
|
609
|
-
const partitions: NormalizedPartition[] = [];
|
|
610
|
-
for (const p of instrument.partitions) {
|
|
611
|
-
if (!p.totalField || !p.pieceFields || p.pieceFields.length === 0) continue;
|
|
612
|
-
const total = `fields.${p.totalField}`;
|
|
613
|
-
const pieces = p.pieceFields.map((f) => `fields.${f}`);
|
|
614
|
-
if (new Set(pieces).size !== pieces.length) continue;
|
|
615
|
-
if (pieces.includes(total) && pieces.length > 1) continue;
|
|
616
|
-
partitions.push({ total, pieces });
|
|
617
|
-
}
|
|
618
|
-
return partitions;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function tryPartitionSubstitution(
|
|
622
|
-
instrument: FinancialInstrument,
|
|
623
|
-
balance: Balance,
|
|
624
|
-
holds: Readonly<Record<string, Balance>>,
|
|
625
|
-
exchanged: boolean,
|
|
626
|
-
targetAmount: string,
|
|
627
|
-
): Balance | null {
|
|
628
|
-
if (exchanged) return null;
|
|
629
|
-
if (balance.kind !== "funded") return null;
|
|
630
|
-
if (Object.keys(holds).length > 0) return null;
|
|
631
|
-
|
|
632
|
-
const declaredPartitions = getInstrumentPartitions(instrument);
|
|
633
|
-
if (declaredPartitions.length === 0) return null;
|
|
634
|
-
|
|
635
|
-
for (const pOut of declaredPartitions) {
|
|
636
|
-
const total = pOut.total;
|
|
637
|
-
|
|
638
|
-
let candidateOutPieces: readonly string[] | null = null;
|
|
639
|
-
if (pOut.pieces.includes(targetAmount)) {
|
|
640
|
-
candidateOutPieces = pOut.pieces;
|
|
641
|
-
} else if (targetAmount === total) {
|
|
642
|
-
candidateOutPieces = [total];
|
|
643
|
-
}
|
|
644
|
-
if (!candidateOutPieces) continue;
|
|
645
|
-
|
|
646
|
-
if (candidateOutPieces.length === 0) continue;
|
|
647
|
-
if (new Set(candidateOutPieces).size !== candidateOutPieces.length)
|
|
648
|
-
continue;
|
|
649
|
-
|
|
650
|
-
const incomingCandidates: (readonly string[])[] = [];
|
|
651
|
-
|
|
652
|
-
if (balance.amounts.includes(total)) {
|
|
653
|
-
incomingCandidates.push([total]);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
for (const pIn of declaredPartitions) {
|
|
657
|
-
if (pIn.total !== total) continue;
|
|
658
|
-
if (pIn.pieces.every((piece) => balance.amounts.includes(piece))) {
|
|
659
|
-
incomingCandidates.push(pIn.pieces);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
for (const candidateInPieces of incomingCandidates) {
|
|
664
|
-
if (
|
|
665
|
-
candidateInPieces.length === candidateOutPieces.length &&
|
|
666
|
-
candidateInPieces.every((piece) => candidateOutPieces!.includes(piece))
|
|
667
|
-
) {
|
|
668
|
-
continue;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (
|
|
672
|
-
balance.amounts.length === candidateInPieces.length &&
|
|
673
|
-
candidateInPieces.every((piece) => balance.amounts.includes(piece))
|
|
674
|
-
) {
|
|
675
|
-
return funded(candidateOutPieces);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return null;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function applyEffects(
|
|
684
|
-
instrument: FinancialInstrument,
|
|
685
|
-
account: string,
|
|
686
|
-
action: string,
|
|
687
|
-
input: AccountState,
|
|
688
|
-
effects: readonly Effect[],
|
|
689
|
-
reservations: ReadonlyMap<string, Reservation>,
|
|
690
|
-
exactBalance: boolean,
|
|
691
|
-
add: (path: readonly PropertyKey[], message: string) => void,
|
|
692
|
-
): AccountState {
|
|
693
|
-
let balance = input.balance;
|
|
694
|
-
const holds = { ...input.holds };
|
|
695
|
-
let exchanged = input.exchanged ?? false;
|
|
696
|
-
const accountLabel = formatAccount(account);
|
|
697
|
-
for (const effect of effects) {
|
|
698
|
-
const fail = (message: string): void =>
|
|
699
|
-
add([...effect.path, "sourceAccountId"], `action ${action} ${message}`);
|
|
700
|
-
if (effect.kind === "credit") {
|
|
701
|
-
if (
|
|
702
|
-
exactBalance &&
|
|
703
|
-
(balance.kind !== "empty" || Object.keys(holds).length > 0)
|
|
704
|
-
) {
|
|
705
|
-
add(
|
|
706
|
-
[...effect.path, "destinationAccountId"],
|
|
707
|
-
`action ${action} funds ${accountLabel} while earlier value may remain; quoted funding must establish exactly one refundable balance`,
|
|
708
|
-
);
|
|
709
|
-
balance = UNKNOWN;
|
|
710
|
-
continue;
|
|
711
|
-
}
|
|
712
|
-
const pieces = balance.kind === "funded" ? balance.amounts : [];
|
|
713
|
-
const stacks =
|
|
714
|
-
(balance.kind === "empty" || balance.kind === "funded") &&
|
|
715
|
-
!pieces.includes(effect.amount);
|
|
716
|
-
balance = stacks ? funded([...pieces, effect.amount]) : UNKNOWN;
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
if (effect.kind === "incoming_reserve") continue;
|
|
720
|
-
if (effect.kind === "outgoing_reserve") {
|
|
721
|
-
let held =
|
|
722
|
-
balance.kind === "funded" && balance.amounts.includes(effect.amount);
|
|
723
|
-
if (!held && balance.kind === "funded") {
|
|
724
|
-
const substituted = tryPartitionSubstitution(
|
|
725
|
-
instrument,
|
|
726
|
-
balance,
|
|
727
|
-
holds,
|
|
728
|
-
exchanged,
|
|
729
|
-
effect.amount,
|
|
730
|
-
);
|
|
731
|
-
if (substituted !== null) {
|
|
732
|
-
exchanged = true;
|
|
733
|
-
balance = substituted;
|
|
734
|
-
held =
|
|
735
|
-
balance.kind === "funded" &&
|
|
736
|
-
balance.amounts.includes(effect.amount);
|
|
737
184
|
}
|
|
738
185
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
186
|
+
for (const key of local.keys()) calculated.set(key, amount({ field: key }));
|
|
187
|
+
const debit = (account: string, value: Balance) => {
|
|
188
|
+
if (owned.has(account))
|
|
189
|
+
state.balances.set(
|
|
190
|
+
account,
|
|
191
|
+
add(
|
|
192
|
+
state.balances.has(account)
|
|
193
|
+
? state.balances.get(account)!
|
|
194
|
+
: new Map(),
|
|
195
|
+
value,
|
|
196
|
+
-1n,
|
|
197
|
+
),
|
|
742
198
|
);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
held && balance.kind === "funded"
|
|
749
|
-
? funded(withoutPiece(balance.amounts, effect.amount))
|
|
750
|
-
: EMPTY;
|
|
751
|
-
continue;
|
|
752
|
-
}
|
|
753
|
-
if (effect.kind === "post" || effect.kind === "void") {
|
|
754
|
-
const key = effect.reservation as string;
|
|
755
|
-
const reservation = reservations.get(key);
|
|
756
|
-
if (
|
|
757
|
-
reservation?.source &&
|
|
758
|
-
accountsMayAlias(instrument, reservation.source, account)
|
|
759
|
-
) {
|
|
760
|
-
const held = holds[key];
|
|
761
|
-
delete holds[key];
|
|
762
|
-
if (effect.kind === "void" && held) {
|
|
763
|
-
const pieces = balance.kind === "funded" ? balance.amounts : [];
|
|
764
|
-
const restorable =
|
|
765
|
-
held.kind === "funded" &&
|
|
766
|
-
(balance.kind === "empty" ||
|
|
767
|
-
(balance.kind === "funded" &&
|
|
768
|
-
held.amounts.every((piece) => !pieces.includes(piece))));
|
|
769
|
-
if (restorable && held.kind === "funded") {
|
|
770
|
-
balance = funded([...pieces, ...held.amounts]);
|
|
771
|
-
} else if (balance.kind === "empty") {
|
|
772
|
-
balance = held;
|
|
773
|
-
} else {
|
|
774
|
-
add(
|
|
775
|
-
[...effect.path, "transferId"],
|
|
776
|
-
`action ${action} restores a held ${accountLabel} balance on top of existing value`,
|
|
777
|
-
);
|
|
778
|
-
balance = UNKNOWN;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
if (effect.kind === "post" && reservation?.destination === account) {
|
|
783
|
-
if (balance.kind !== "empty" || Object.keys(holds).length > 0) {
|
|
199
|
+
};
|
|
200
|
+
const credit = (account: string, value: Balance) => {
|
|
201
|
+
if (owned.has(account))
|
|
202
|
+
state.balances.set(
|
|
203
|
+
account,
|
|
784
204
|
add(
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
balance = { kind: "funded", amounts: [effect.amount] };
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
if (effect.kind === "refund") {
|
|
796
|
-
const refundable = `fields.${effect.refundable ?? ""}`;
|
|
797
|
-
if (
|
|
798
|
-
balance.kind !== "funded" ||
|
|
799
|
-
balance.amounts.length !== 1 ||
|
|
800
|
-
balance.amounts[0] !== refundable
|
|
801
|
-
) {
|
|
802
|
-
fail(
|
|
803
|
-
`cannot refund ${refundable} from ${accountLabel}; that exact balance is not guaranteed`,
|
|
205
|
+
state.balances.has(account)
|
|
206
|
+
? state.balances.get(account)!
|
|
207
|
+
: new Map(),
|
|
208
|
+
value,
|
|
209
|
+
),
|
|
804
210
|
);
|
|
805
|
-
|
|
211
|
+
};
|
|
212
|
+
for (const move of action.moves) {
|
|
213
|
+
if ("amount" in move) {
|
|
214
|
+
const value = amount(move.amount);
|
|
215
|
+
debit(move.from, value);
|
|
216
|
+
if (move.operation === "internal_transfer.reserve") {
|
|
217
|
+
const key = `self.${move.capture}`;
|
|
218
|
+
if (state.holds.has(key))
|
|
219
|
+
report(`${move.capture} overwrites a live reservation`);
|
|
220
|
+
state.holds.set(key, { from: move.from, to: move.to, amount: value });
|
|
221
|
+
} else credit(move.to, value);
|
|
806
222
|
} else {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (balance.kind !== "penalty") {
|
|
816
|
-
fail(
|
|
817
|
-
`cannot pay the quoted charge from ${accountLabel} before the refund leaves that exact remainder`,
|
|
223
|
+
const hold = state.holds.get(move.transfer);
|
|
224
|
+
if (!hold) {
|
|
225
|
+
report(`${move.key} consumes a missing reservation ${move.transfer}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
credit(
|
|
229
|
+
move.operation === "internal_transfer.post" ? hold.to : hold.from,
|
|
230
|
+
hold.amount,
|
|
818
231
|
);
|
|
232
|
+
state.holds.delete(move.transfer);
|
|
819
233
|
}
|
|
820
|
-
balance = EMPTY;
|
|
821
|
-
continue;
|
|
822
234
|
}
|
|
823
|
-
if (
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
);
|
|
830
|
-
}
|
|
831
|
-
balance = balance.kind === "empty" ? EMPTY : { kind: "unknown" };
|
|
832
|
-
continue;
|
|
235
|
+
if (
|
|
236
|
+
Object.keys(action.set ?? {}).some((key) =>
|
|
237
|
+
instrument.fields.some((f) => f.name === key && f.type === "money"),
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
for (const account of owned) state.balances.set(account, null);
|
|
833
241
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
242
|
+
};
|
|
243
|
+
const initial: State = {
|
|
244
|
+
status: instrument.lifecycle.initial,
|
|
245
|
+
balances: new Map([...owned].map((key) => [key, new Map()])),
|
|
246
|
+
holds: new Map(),
|
|
247
|
+
};
|
|
248
|
+
if (instrument.actions.create)
|
|
249
|
+
apply(initial, instrument.actions.create, "create");
|
|
250
|
+
const pending = [initial];
|
|
251
|
+
const reached = new Map<string, State>();
|
|
252
|
+
const copy = (state: State): State => ({
|
|
253
|
+
status: state.status,
|
|
254
|
+
balances: new Map(state.balances),
|
|
255
|
+
holds: new Map([...state.holds].map(([key, hold]) => [key, { ...hold }])),
|
|
256
|
+
});
|
|
257
|
+
let work = 0;
|
|
258
|
+
while (pending.length && !problems.length) {
|
|
259
|
+
if (++work > 4096) {
|
|
260
|
+
report("owned proof exceeds its finite work budget");
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
let state = pending.pop()!;
|
|
264
|
+
const key = JSON.stringify([state.status, [...state.holds.keys()].sort()]);
|
|
265
|
+
const previous = reached.get(key);
|
|
266
|
+
if (previous) {
|
|
267
|
+
const joined = copy(previous);
|
|
268
|
+
let changed = false;
|
|
269
|
+
for (const account of owned) {
|
|
270
|
+
const before = previous.balances.has(account)
|
|
271
|
+
? previous.balances.get(account)!
|
|
272
|
+
: new Map();
|
|
273
|
+
const after = state.balances.has(account)
|
|
274
|
+
? state.balances.get(account)!
|
|
275
|
+
: new Map();
|
|
276
|
+
if (before !== null && !same(before, after)) {
|
|
277
|
+
joined.balances.set(account, null);
|
|
278
|
+
changed = true;
|
|
855
279
|
}
|
|
856
280
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
`can debit ${accountLabel} after its refund left only the quoted charge`,
|
|
867
|
-
);
|
|
868
|
-
} else if (balance.kind === "funded" || exactBalance) {
|
|
869
|
-
fail(
|
|
870
|
-
`cannot prove ${effect.amount} is the exact available balance of ${accountLabel}`,
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
balance = EMPTY;
|
|
874
|
-
}
|
|
875
|
-
// A held account permits at most one equal-partition substitution per funded
|
|
876
|
-
// balance, preventing cycles, duplicate minting, and double spending. When an
|
|
877
|
-
// account is completely drained to empty with no active holds, the exchange
|
|
878
|
-
// record resets for any distinct subsequent funding.
|
|
879
|
-
if (balance.kind === "empty" && Object.keys(holds).length === 0) {
|
|
880
|
-
exchanged = false;
|
|
881
|
-
}
|
|
882
|
-
return { balance, holds, exchanged };
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
/**
|
|
886
|
-
* Flattens every quote-commit pair the instrument declares. A quoting action
|
|
887
|
-
* with no committing action, or a commit naming a non-quoting action, is a
|
|
888
|
-
* validator failure rather than a typestate one, so it is simply skipped here.
|
|
889
|
-
*/
|
|
890
|
-
function quoteFacts(
|
|
891
|
-
instrument: FinancialInstrument,
|
|
892
|
-
options: FinanceOptions,
|
|
893
|
-
): readonly QuoteFacts[] {
|
|
894
|
-
const facts: QuoteFacts[] = [];
|
|
895
|
-
for (const [quoting, action] of Object.entries(instrument.actions)) {
|
|
896
|
-
const quote = action.quote;
|
|
897
|
-
if (!quote) continue;
|
|
898
|
-
const commit = Object.entries(instrument.actions).find(
|
|
899
|
-
([, candidate]) => candidate.commit === quoting,
|
|
900
|
-
)?.[0];
|
|
901
|
-
if (commit === undefined) continue;
|
|
902
|
-
const chargePath = `refs.${quote.chargeRef}`;
|
|
903
|
-
const declaresChargePayout = Object.values(instrument.actions).some(
|
|
904
|
-
(candidate) =>
|
|
905
|
-
(candidate.moves ?? []).some((move) =>
|
|
906
|
-
Object.values(move.bind).some(
|
|
907
|
-
(binding) =>
|
|
908
|
-
binding.from === "instance" && binding.path === chargePath,
|
|
909
|
-
),
|
|
910
|
-
),
|
|
911
|
-
);
|
|
912
|
-
facts.push({
|
|
913
|
-
baseField: quote.baseField,
|
|
914
|
-
chargeCanBeNonzero:
|
|
915
|
-
options.penaltyMayBeNonzero === true ||
|
|
916
|
-
declaresChargePayout ||
|
|
917
|
-
quote.charges.some((tier) => tier.bps > 0),
|
|
918
|
-
chargeRef: quote.chargeRef,
|
|
919
|
-
chargeRetainedBy: quote.chargeRetainedBy,
|
|
920
|
-
commit,
|
|
921
|
-
netRef: quote.netRef,
|
|
922
|
-
quoting,
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
return facts;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
/**
|
|
929
|
-
* The quoted charge is the exact remainder the commit leaves behind, so it may
|
|
930
|
-
* fund one transfer, out of the same account the refund left, in its own
|
|
931
|
-
* one-way action directly after the commit.
|
|
932
|
-
*/
|
|
933
|
-
function validateChargePayout(
|
|
934
|
-
instrument: FinancialInstrument,
|
|
935
|
-
quote: QuoteFacts,
|
|
936
|
-
refundSource: string | undefined,
|
|
937
|
-
add: (path: readonly PropertyKey[], message: string) => void,
|
|
938
|
-
): void {
|
|
939
|
-
const chargePath = `refs.${quote.chargeRef}`;
|
|
940
|
-
const uses = Object.entries(instrument.actions).flatMap(
|
|
941
|
-
([actionName, action]) =>
|
|
942
|
-
(action.moves ?? []).flatMap((step, stepIndex) =>
|
|
943
|
-
Object.entries(step.bind).flatMap(([target, binding]) =>
|
|
944
|
-
binding.from === "instance" && binding.path === chargePath
|
|
945
|
-
? [{ binding, step, stepIndex, target, action, actionName }]
|
|
946
|
-
: [],
|
|
947
|
-
),
|
|
948
|
-
),
|
|
949
|
-
);
|
|
950
|
-
|
|
951
|
-
if (quote.chargeRetainedBy !== undefined) {
|
|
952
|
-
const role = quote.chargeRetainedBy;
|
|
953
|
-
const partyField = Object.hasOwn(instrument.parties ?? {}, role)
|
|
954
|
-
? instrument.parties?.[
|
|
955
|
-
role as keyof NonNullable<FinancialInstrument["parties"]>
|
|
956
|
-
]
|
|
957
|
-
: undefined;
|
|
958
|
-
if (!partyField) {
|
|
959
|
-
add(
|
|
960
|
-
["actions", quote.quoting, "quote", "chargeRetainedBy"],
|
|
961
|
-
`quoting action ${quote.quoting} retains charge by undeclared party role ${role}`,
|
|
962
|
-
);
|
|
963
|
-
} else {
|
|
964
|
-
const expectedSource = `field:${partyField}`;
|
|
965
|
-
if (refundSource === undefined) {
|
|
966
|
-
add(
|
|
967
|
-
["actions", quote.commit, "moves"],
|
|
968
|
-
`commit action ${quote.commit} refund source cannot be resolved to charge-retaining party field ${partyField}`,
|
|
969
|
-
);
|
|
970
|
-
} else if (refundSource !== expectedSource) {
|
|
971
|
-
if (refundSource.startsWith("ref:")) {
|
|
972
|
-
add(
|
|
973
|
-
["actions", quote.commit, "moves"],
|
|
974
|
-
`commit action ${quote.commit} refund source cannot be a product escrow ref for a retained quote`,
|
|
975
|
-
);
|
|
976
|
-
} else {
|
|
977
|
-
add(
|
|
978
|
-
["actions", quote.commit, "moves"],
|
|
979
|
-
`commit action ${quote.commit} refund source ${refundSource} does not match charge-retaining party field ${partyField}`,
|
|
980
|
-
);
|
|
281
|
+
for (const [name, hold] of joined.holds) {
|
|
282
|
+
const next = state.holds.get(name)!;
|
|
283
|
+
if (hold.from !== next.from || hold.to !== next.to) {
|
|
284
|
+
report(`${name} changes reservation endpoints across paths`);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
if (hold.amount !== null && !same(hold.amount, next.amount)) {
|
|
288
|
+
hold.amount = null;
|
|
289
|
+
changed = true;
|
|
981
290
|
}
|
|
982
291
|
}
|
|
292
|
+
if (!changed) continue;
|
|
293
|
+
state = joined;
|
|
983
294
|
}
|
|
984
|
-
if (
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
`${chargePath} is retained by ${role} and cannot be consumed by an action`,
|
|
988
|
-
);
|
|
989
|
-
for (const use of uses) {
|
|
990
|
-
add(
|
|
991
|
-
[
|
|
992
|
-
"actions",
|
|
993
|
-
use.actionName,
|
|
994
|
-
"moves",
|
|
995
|
-
use.stepIndex,
|
|
996
|
-
"bind",
|
|
997
|
-
use.target,
|
|
998
|
-
],
|
|
999
|
-
`${chargePath} is retained by ${role} and cannot be consumed by an action`,
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
295
|
+
if (reached.size >= 256 && !previous) {
|
|
296
|
+
report("owned proof exceeds 256 state and reservation combinations");
|
|
297
|
+
break;
|
|
1002
298
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
if (quote.chargeCanBeNonzero && uses.length !== 1) {
|
|
1007
|
-
add(
|
|
1008
|
-
["actions"],
|
|
1009
|
-
`${chargePath} is consumed ${uses.length} times; a nonzero charge schedule requires exactly one payout`,
|
|
299
|
+
reached.set(key, copy(state));
|
|
300
|
+
const edges = Object.entries(instrument.lifecycle.transitions).filter(
|
|
301
|
+
([, edge]) => edge.from.includes(state.status),
|
|
1010
302
|
);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
303
|
+
if (
|
|
304
|
+
!edges.length &&
|
|
305
|
+
([...state.balances].some(
|
|
306
|
+
([account, balance]) =>
|
|
307
|
+
!shared.has(account) && (balance === null || balance.size > 0),
|
|
308
|
+
) ||
|
|
309
|
+
state.holds.size)
|
|
310
|
+
)
|
|
311
|
+
report(
|
|
312
|
+
`terminal state ${state.status} may strand owned money or an open reservation`,
|
|
1018
313
|
);
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const source = canonicalAccount(instrument, use.step.bind.sourceAccountId);
|
|
1027
|
-
const destination = canonicalAccount(
|
|
1028
|
-
instrument,
|
|
1029
|
-
use.step.bind.destinationAccountId,
|
|
1030
|
-
);
|
|
1031
|
-
const directAfterCommit =
|
|
1032
|
-
commitTarget !== undefined &&
|
|
1033
|
-
payoutTransition?.from.length === 1 &&
|
|
1034
|
-
payoutTransition.from[0] === commitTarget;
|
|
1035
|
-
const oneWay =
|
|
1036
|
-
payoutTransition !== undefined &&
|
|
1037
|
-
payoutTransition.to !== commitTarget &&
|
|
1038
|
-
!stateCanReach(instrument, payoutTransition.to, commitTarget);
|
|
1039
|
-
const valid =
|
|
1040
|
-
use.target === "amount" &&
|
|
1041
|
-
use.step.operation === "internal_transfer.create" &&
|
|
1042
|
-
(use.action.moves?.length ?? 0) === 1 &&
|
|
1043
|
-
directAfterCommit &&
|
|
1044
|
-
oneWay &&
|
|
1045
|
-
source !== undefined &&
|
|
1046
|
-
source === refundSource &&
|
|
1047
|
-
destination !== undefined &&
|
|
1048
|
-
!accountsMayAlias(instrument, source, destination);
|
|
1049
|
-
if (!valid) {
|
|
1050
|
-
add(
|
|
1051
|
-
["actions", use.actionName, "moves", use.stepIndex, "bind", use.target],
|
|
1052
|
-
`${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}`,
|
|
1053
|
-
);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function canonicalAccount(
|
|
1058
|
-
instrument: FinancialInstrument,
|
|
1059
|
-
binding: UdlBinding | undefined,
|
|
1060
|
-
): string | undefined {
|
|
1061
|
-
if (binding?.from !== "instance") return undefined;
|
|
1062
|
-
return canonicalAccountPath(instrument, binding.path);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
function canonicalAccountPath(
|
|
1066
|
-
instrument: FinancialInstrument,
|
|
1067
|
-
path: string,
|
|
1068
|
-
): string | undefined {
|
|
1069
|
-
const [root, key, extra] = path.split(".");
|
|
1070
|
-
if (!key || extra !== undefined) return undefined;
|
|
1071
|
-
if (root === "fields") return `field:${key}`;
|
|
1072
|
-
if (root === "refs") return `ref:${key}`;
|
|
1073
|
-
if (root === "party") {
|
|
1074
|
-
const field =
|
|
1075
|
-
instrument.parties?.[
|
|
1076
|
-
key as keyof NonNullable<FinancialInstrument["parties"]>
|
|
1077
|
-
];
|
|
1078
|
-
return field ? `field:${field}` : undefined;
|
|
1079
|
-
}
|
|
1080
|
-
return undefined;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function accountsMayAlias(
|
|
1084
|
-
instrument: FinancialInstrument,
|
|
1085
|
-
left: string,
|
|
1086
|
-
right: string,
|
|
1087
|
-
): boolean {
|
|
1088
|
-
if (left === right) return true;
|
|
1089
|
-
if (left.startsWith("field:") && right.startsWith("field:")) {
|
|
1090
|
-
const leftField = left.slice("field:".length);
|
|
1091
|
-
const rightField = right.slice("field:".length);
|
|
1092
|
-
if (leftField === rightField) return true;
|
|
1093
|
-
if (instrument.parties) {
|
|
1094
|
-
const leftParty = Object.entries(instrument.parties).find(
|
|
1095
|
-
([, field]) => field === leftField,
|
|
1096
|
-
)?.[0];
|
|
1097
|
-
const rightParty = Object.entries(instrument.parties).find(
|
|
1098
|
-
([, field]) => field === rightField,
|
|
1099
|
-
)?.[0];
|
|
1100
|
-
if (
|
|
1101
|
-
leftParty !== undefined &&
|
|
1102
|
-
rightParty !== undefined &&
|
|
1103
|
-
leftParty !== rightParty
|
|
1104
|
-
) {
|
|
1105
|
-
return false;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
return true;
|
|
1109
|
-
}
|
|
1110
|
-
return false;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
function amountIdentity(binding: UdlBinding | undefined): string {
|
|
1114
|
-
if (binding?.from === "instance") return binding.path;
|
|
1115
|
-
return binding?.from === "const" ? `const:${binding.value}` : "dynamic";
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
function transferRefKey(step: FinancialStep): string | undefined {
|
|
1119
|
-
return Object.entries(step.capture ?? {}).find(
|
|
1120
|
-
([, result]) => result === "transferId",
|
|
1121
|
-
)?.[0];
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function boundTransferRefKey(step: FinancialStep): string | undefined {
|
|
1125
|
-
const binding = step.bind.transferId;
|
|
1126
|
-
return binding?.from === "instance" && binding.path.startsWith("refs.")
|
|
1127
|
-
? binding.path.slice("refs.".length)
|
|
1128
|
-
: undefined;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function stateCanReach(
|
|
1132
|
-
instrument: FinancialInstrument,
|
|
1133
|
-
start: string,
|
|
1134
|
-
target: string | undefined,
|
|
1135
|
-
): boolean {
|
|
1136
|
-
if (!target) return false;
|
|
1137
|
-
const seen = new Set([start]);
|
|
1138
|
-
const pending = [start];
|
|
1139
|
-
while (pending.length > 0) {
|
|
1140
|
-
const state = pending.shift() as string;
|
|
1141
|
-
if (state === target) return true;
|
|
1142
|
-
for (const transition of Object.values(instrument.lifecycle.transitions)) {
|
|
1143
|
-
if (!transition.from.includes(state) || seen.has(transition.to)) continue;
|
|
1144
|
-
seen.add(transition.to);
|
|
1145
|
-
pending.push(transition.to);
|
|
314
|
+
for (const [name, edge] of edges) {
|
|
315
|
+
const action = instrument.actions[name];
|
|
316
|
+
if (!action) continue;
|
|
317
|
+
const next = copy(state);
|
|
318
|
+
next.status = edge.to;
|
|
319
|
+
apply(next, action, name);
|
|
320
|
+
pending.push(next);
|
|
1146
321
|
}
|
|
1147
322
|
}
|
|
1148
|
-
return
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
function stateKey(state: AccountState): string {
|
|
1152
|
-
return JSON.stringify({
|
|
1153
|
-
balance: state.balance,
|
|
1154
|
-
holds: Object.fromEntries(
|
|
1155
|
-
Object.entries(state.holds).sort(([a], [b]) => a.localeCompare(b)),
|
|
1156
|
-
),
|
|
1157
|
-
exchanged: state.exchanged ?? false,
|
|
1158
|
-
});
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
function formatAccount(account: string): string {
|
|
1162
|
-
const [kind, key] = account.split(":");
|
|
1163
|
-
return `${kind === "field" ? "fields" : "refs"}.${key}`;
|
|
323
|
+
return problems;
|
|
1164
324
|
}
|