@hyperscale0/hsx 1.0.0-alpha.1
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/AUTHORS +8 -0
- package/CHANGELOG.md +59 -0
- package/LICENSE +661 -0
- package/LICENSING.md +52 -0
- package/README.md +170 -0
- package/SECURITY.md +47 -0
- package/TRADEMARKS.md +35 -0
- package/bin/hsx.ts +15 -0
- package/dist/bin/hsx.d.ts +7 -0
- package/dist/bin/hsx.d.ts.map +1 -0
- package/dist/bin/hsx.js +14 -0
- package/dist/bin/hsx.js.map +1 -0
- package/dist/src/ast.d.ts +172 -0
- package/dist/src/ast.d.ts.map +1 -0
- package/dist/src/ast.js +22 -0
- package/dist/src/ast.js.map +1 -0
- package/dist/src/check.d.ts +11 -0
- package/dist/src/check.d.ts.map +1 -0
- package/dist/src/check.js +1214 -0
- package/dist/src/check.js.map +1 -0
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +137 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/compile.d.ts +39 -0
- package/dist/src/compile.d.ts.map +1 -0
- package/dist/src/compile.js +59 -0
- package/dist/src/compile.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +23 -0
- package/dist/src/lex.d.ts.map +1 -0
- package/dist/src/lex.js +125 -0
- package/dist/src/lex.js.map +1 -0
- package/dist/src/lower.d.ts +93 -0
- package/dist/src/lower.d.ts.map +1 -0
- package/dist/src/lower.js +2081 -0
- package/dist/src/lower.js.map +1 -0
- package/dist/src/model.d.ts +307 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +15 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/parse.d.ts +19 -0
- package/dist/src/parse.d.ts.map +1 -0
- package/dist/src/parse.js +484 -0
- package/dist/src/parse.js.map +1 -0
- package/dist/src/version.d.ts +16 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/src/version.js +16 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +79 -0
- package/spec/hsx-ir.schema.json +522 -0
- package/src/ast.ts +231 -0
- package/src/check.ts +1699 -0
- package/src/cli.ts +173 -0
- package/src/compile.ts +98 -0
- package/src/index.ts +16 -0
- package/src/lex.ts +161 -0
- package/src/lower.ts +2619 -0
- package/src/model.ts +340 -0
- package/src/parse.ts +580 -0
- package/src/version.ts +17 -0
package/src/check.ts
ADDED
|
@@ -0,0 +1,1699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HSX typechecker. Takes the parsed AST, resolves every name, validates
|
|
3
|
+
* each archetype's parameter surface, and produces the checked program model
|
|
4
|
+
* the lowering consumes. Total: always returns diagnostics; returns the model
|
|
5
|
+
* exactly when nothing error-severity was found. Warnings are the compiler's
|
|
6
|
+
* lint voice and never block.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
BlockExpr,
|
|
11
|
+
Entry,
|
|
12
|
+
Expr,
|
|
13
|
+
IdentExpr,
|
|
14
|
+
Program,
|
|
15
|
+
Span,
|
|
16
|
+
} from "./ast.ts";
|
|
17
|
+
import {
|
|
18
|
+
ASSET_KINDS,
|
|
19
|
+
PARTY_KINDS,
|
|
20
|
+
type AdvanceSource,
|
|
21
|
+
type AssetKind,
|
|
22
|
+
type CancelPolicy,
|
|
23
|
+
type CheckDiagnostic,
|
|
24
|
+
type CheckResult,
|
|
25
|
+
type CheckedAsset,
|
|
26
|
+
type CheckedParty,
|
|
27
|
+
type CheckedPort,
|
|
28
|
+
type CheckedSettlement,
|
|
29
|
+
type FeeTerm,
|
|
30
|
+
type MeterRate,
|
|
31
|
+
type MoneyField,
|
|
32
|
+
type PartyKind,
|
|
33
|
+
type PortField,
|
|
34
|
+
type PortFieldType,
|
|
35
|
+
type ScheduleTerms,
|
|
36
|
+
type SplitShare,
|
|
37
|
+
type SwapFee,
|
|
38
|
+
} from "./model.ts";
|
|
39
|
+
|
|
40
|
+
/** Every archetype the settlement stdlib ships; all of them lower today. */
|
|
41
|
+
const SETTLEMENT_ARCHETYPES = [
|
|
42
|
+
"advance",
|
|
43
|
+
"deposit",
|
|
44
|
+
"held_payment",
|
|
45
|
+
"instant_transfer",
|
|
46
|
+
"metered",
|
|
47
|
+
"pooled_split",
|
|
48
|
+
"premium_forward",
|
|
49
|
+
"scheduled",
|
|
50
|
+
"swap",
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
type ArchetypeName = (typeof SETTLEMENT_ARCHETYPES)[number];
|
|
54
|
+
|
|
55
|
+
/** Each archetype's parameter surface: the entries its body understands. */
|
|
56
|
+
const ARCHETYPE_SURFACES: Record<
|
|
57
|
+
ArchetypeName,
|
|
58
|
+
{ readonly keys: readonly string[]; readonly required: readonly string[] }
|
|
59
|
+
> = {
|
|
60
|
+
advance: {
|
|
61
|
+
keys: [
|
|
62
|
+
"against",
|
|
63
|
+
"amount",
|
|
64
|
+
"count",
|
|
65
|
+
"every",
|
|
66
|
+
"fee",
|
|
67
|
+
"first_due",
|
|
68
|
+
"funder",
|
|
69
|
+
"to",
|
|
70
|
+
],
|
|
71
|
+
// The repayment source is one of two shapes (a schedule, or a carve out of
|
|
72
|
+
// a hold's release), so the advance case below states that requirement
|
|
73
|
+
// itself rather than listing either shape's keys here.
|
|
74
|
+
required: ["funder", "to", "amount"],
|
|
75
|
+
},
|
|
76
|
+
deposit: {
|
|
77
|
+
keys: ["amount", "claim", "holder", "payer", "return"],
|
|
78
|
+
required: ["payer", "holder", "amount", "claim", "return"],
|
|
79
|
+
},
|
|
80
|
+
held_payment: {
|
|
81
|
+
keys: ["amount", "fees", "on_cancel", "payer", "payee", "release"],
|
|
82
|
+
required: ["payer", "payee", "amount", "release"],
|
|
83
|
+
},
|
|
84
|
+
instant_transfer: {
|
|
85
|
+
keys: ["amount", "fees", "payer", "payee"],
|
|
86
|
+
required: ["payer", "payee", "amount"],
|
|
87
|
+
},
|
|
88
|
+
metered: {
|
|
89
|
+
keys: ["close_by", "payer", "payee", "rates"],
|
|
90
|
+
required: ["payer", "payee", "rates", "close_by"],
|
|
91
|
+
},
|
|
92
|
+
pooled_split: {
|
|
93
|
+
keys: ["amount", "payer", "payout_due", "split"],
|
|
94
|
+
required: ["payer", "amount", "split", "payout_due"],
|
|
95
|
+
},
|
|
96
|
+
premium_forward: {
|
|
97
|
+
keys: ["amount", "bind", "carrier", "commission", "on_cancel", "payer"],
|
|
98
|
+
required: ["payer", "carrier", "amount", "bind"],
|
|
99
|
+
},
|
|
100
|
+
scheduled: {
|
|
101
|
+
keys: ["amount", "count", "every", "first_due", "payer", "payee"],
|
|
102
|
+
required: ["payer", "payee", "amount", "count", "every", "first_due"],
|
|
103
|
+
},
|
|
104
|
+
swap: {
|
|
105
|
+
keys: ["amounts", "between", "dispute", "fees", "release"],
|
|
106
|
+
required: ["between", "amounts", "release"],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The three terms that together declare a finite schedule. */
|
|
111
|
+
const SCHEDULE_KEYS = ["count", "every", "first_due"] as const;
|
|
112
|
+
|
|
113
|
+
const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
114
|
+
const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
|
|
115
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
116
|
+
|
|
117
|
+
const TOTAL_BPS = 10_000;
|
|
118
|
+
|
|
119
|
+
export function checkProgram(program: Program): CheckResult {
|
|
120
|
+
const diagnostics: CheckDiagnostic[] = [];
|
|
121
|
+
const error = (span: Span, message: string): void => {
|
|
122
|
+
diagnostics.push({ message, severity: "error", span });
|
|
123
|
+
};
|
|
124
|
+
const warning = (span: Span, message: string): void => {
|
|
125
|
+
diagnostics.push({ message, severity: "warning", span });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// --- Header ---------------------------------------------------------------
|
|
129
|
+
const headers = program.decls.filter((decl) => decl.kind === "program");
|
|
130
|
+
const header = headers[0];
|
|
131
|
+
if (!header) {
|
|
132
|
+
error(
|
|
133
|
+
{ end: 0, start: 0 },
|
|
134
|
+
'the file needs a program header naming the company, like: program used_car_escrow "Used-car escrow"',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
for (const extra of headers.slice(1)) {
|
|
138
|
+
error(extra.span, "a file declares exactly one program");
|
|
139
|
+
}
|
|
140
|
+
if (header && !SNAKE_CASE.test(header.name.name)) {
|
|
141
|
+
error(
|
|
142
|
+
header.name.span,
|
|
143
|
+
`the program name must be snake_case, like used_car_escrow; "${header.name.name}" is not`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Imports --------------------------------------------------------------
|
|
148
|
+
const imported = new Map<string, Span>();
|
|
149
|
+
for (const decl of program.decls) {
|
|
150
|
+
if (decl.kind !== "import") continue;
|
|
151
|
+
if (decl.from.value !== "settlement") {
|
|
152
|
+
error(
|
|
153
|
+
decl.from.span,
|
|
154
|
+
`"${decl.from.value}" is not an importable module; the settlement algebra lives in "settlement"`,
|
|
155
|
+
);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const name of decl.names) {
|
|
159
|
+
if (!(SETTLEMENT_ARCHETYPES as readonly string[]).includes(name.name)) {
|
|
160
|
+
error(
|
|
161
|
+
name.span,
|
|
162
|
+
`"settlement" has no archetype named ${name.name}; it offers ${SETTLEMENT_ARCHETYPES.join(", ")}`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (imported.has(name.name)) {
|
|
167
|
+
warning(name.span, `${name.name} is imported more than once`);
|
|
168
|
+
}
|
|
169
|
+
imported.set(name.name, name.span);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- One namespace for every declared name --------------------------------
|
|
174
|
+
const declared = new Map<string, { kind: string; span: Span }>();
|
|
175
|
+
const claim = (name: IdentExpr, kind: string): boolean => {
|
|
176
|
+
if (name.name === "platform" || name.name === "escrow") {
|
|
177
|
+
error(
|
|
178
|
+
name.span,
|
|
179
|
+
`the name ${name.name} is reserved: the platform and each settlement's escrow are always present without being declared`,
|
|
180
|
+
);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const existing = declared.get(name.name);
|
|
184
|
+
if (existing) {
|
|
185
|
+
error(
|
|
186
|
+
name.span,
|
|
187
|
+
`the name ${name.name} is already taken by a ${existing.kind}; every party, asset, settlement, and port needs its own name`,
|
|
188
|
+
);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!SNAKE_CASE.test(name.name)) {
|
|
192
|
+
error(name.span, `${kind} names are snake_case; "${name.name}" is not`);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
declared.set(name.name, { kind, span: name.span });
|
|
196
|
+
return true;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// --- Parties and assets ---------------------------------------------------
|
|
200
|
+
const parties = new Map<string, CheckedParty>();
|
|
201
|
+
const assets = new Map<string, CheckedAsset>();
|
|
202
|
+
for (const decl of program.decls) {
|
|
203
|
+
if (decl.kind === "party") {
|
|
204
|
+
if (!claim(decl.name, "party")) continue;
|
|
205
|
+
if (!(PARTY_KINDS as readonly string[]).includes(decl.partyKind.name)) {
|
|
206
|
+
error(
|
|
207
|
+
decl.partyKind.span,
|
|
208
|
+
`a party is a ${PARTY_KINDS.join(" or a ")}; "${decl.partyKind.name}" is neither`,
|
|
209
|
+
);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (decl.attrs) {
|
|
213
|
+
error(
|
|
214
|
+
decl.attrs.span,
|
|
215
|
+
`party ${decl.name.name} takes no attribute block yet`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
parties.set(decl.name.name, {
|
|
219
|
+
kind: decl.partyKind.name as PartyKind,
|
|
220
|
+
name: decl.name.name,
|
|
221
|
+
origin: decl.span,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (decl.kind === "asset") {
|
|
225
|
+
if (!claim(decl.name, "asset")) continue;
|
|
226
|
+
if (!(ASSET_KINDS as readonly string[]).includes(decl.assetKind.name)) {
|
|
227
|
+
error(
|
|
228
|
+
decl.assetKind.span,
|
|
229
|
+
`an asset is one of ${ASSET_KINDS.join(", ")}; "${decl.assetKind.name}" is none of those`,
|
|
230
|
+
);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
let titleTransfer: CheckedAsset["titleTransfer"] = "on_platform";
|
|
234
|
+
for (const entry of decl.attrs?.entries ?? []) {
|
|
235
|
+
if (entry.key.name !== "title_transfer") {
|
|
236
|
+
error(
|
|
237
|
+
entry.key.span,
|
|
238
|
+
`asset ${decl.name.name} does not understand "${entry.key.name}"; the only attribute is title_transfer`,
|
|
239
|
+
);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const value = entry.value;
|
|
243
|
+
if (
|
|
244
|
+
value.kind === "ident" &&
|
|
245
|
+
(value.name === "off_platform" || value.name === "on_platform")
|
|
246
|
+
) {
|
|
247
|
+
titleTransfer = value.name;
|
|
248
|
+
} else {
|
|
249
|
+
error(
|
|
250
|
+
value.span,
|
|
251
|
+
"title_transfer is either on_platform or off_platform",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
assets.set(decl.name.name, {
|
|
256
|
+
kind: decl.assetKind.name as AssetKind,
|
|
257
|
+
name: decl.name.name,
|
|
258
|
+
origin: decl.span,
|
|
259
|
+
titleTransfer,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Claim settlement and port names before checking bodies, so declarations
|
|
265
|
+
// may reference each other regardless of file order.
|
|
266
|
+
const portDecls = program.decls.filter((decl) => decl.kind === "port");
|
|
267
|
+
const settlementDecls = program.decls.filter(
|
|
268
|
+
(decl) => decl.kind === "settlement",
|
|
269
|
+
);
|
|
270
|
+
for (const decl of settlementDecls) claim(decl.name, "settlement");
|
|
271
|
+
const portNames = new Set<string>();
|
|
272
|
+
for (const decl of portDecls) {
|
|
273
|
+
if (claim(decl.name, "port")) portNames.add(decl.name.name);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- Shared body helpers ----------------------------------------------------
|
|
277
|
+
const entriesOf = (
|
|
278
|
+
block: BlockExpr,
|
|
279
|
+
valid: readonly string[],
|
|
280
|
+
owner: string,
|
|
281
|
+
): Map<string, Entry> => {
|
|
282
|
+
const seen = new Map<string, Entry>();
|
|
283
|
+
for (const entry of block.entries) {
|
|
284
|
+
if (!valid.includes(entry.key.name)) {
|
|
285
|
+
error(
|
|
286
|
+
entry.key.span,
|
|
287
|
+
`${owner} does not understand "${entry.key.name}"; it takes ${valid.join(", ")}`,
|
|
288
|
+
);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (seen.has(entry.key.name)) {
|
|
292
|
+
error(entry.key.span, `${owner} sets ${entry.key.name} twice`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
seen.set(entry.key.name, entry);
|
|
296
|
+
}
|
|
297
|
+
return seen;
|
|
298
|
+
};
|
|
299
|
+
const noQualifiers = (entry: Entry, owner: string): void => {
|
|
300
|
+
if (entry.qualifiers.length > 0) {
|
|
301
|
+
error(
|
|
302
|
+
entry.span,
|
|
303
|
+
`${entry.key.name} on ${owner} takes no ( ... ) qualifier`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const referencedParties = new Set<string>();
|
|
308
|
+
const partyRef = (expr: Expr, role: string): string | undefined => {
|
|
309
|
+
if (expr.kind === "ident" && parties.has(expr.name)) {
|
|
310
|
+
referencedParties.add(expr.name);
|
|
311
|
+
return expr.name;
|
|
312
|
+
}
|
|
313
|
+
error(
|
|
314
|
+
expr.span,
|
|
315
|
+
expr.kind === "ident"
|
|
316
|
+
? `${role} must name a declared party; there is no party named ${expr.name}`
|
|
317
|
+
: `${role} must name a declared party`,
|
|
318
|
+
);
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// --- Settlements: shared term parsers ---------------------------------------
|
|
323
|
+
const referencedPorts = new Set<string>();
|
|
324
|
+
const settlements: CheckedSettlement[] = [];
|
|
325
|
+
|
|
326
|
+
// The lowering mints fields with these names; a user field that collides
|
|
327
|
+
// would silently overwrite the generated one and corrupt the choreography
|
|
328
|
+
// (e.g. an amount named serviceFeeAmount would BE the fee moved to the
|
|
329
|
+
// platform). Refused here, at source coordinates, for every archetype.
|
|
330
|
+
const RESERVED_FIELD_NAMES = new Set([
|
|
331
|
+
"currency",
|
|
332
|
+
"feeAmount",
|
|
333
|
+
"repayableAmount",
|
|
334
|
+
"serviceFeeAmount",
|
|
335
|
+
"clawbackAt",
|
|
336
|
+
]);
|
|
337
|
+
const RESERVED_FIELD_PATTERNS = [
|
|
338
|
+
/^piece\d+Amount$/,
|
|
339
|
+
/^installment\d+Amount$/,
|
|
340
|
+
/^repayment\d+Amount$/,
|
|
341
|
+
/ShareAmount$/,
|
|
342
|
+
/AccountId$/,
|
|
343
|
+
];
|
|
344
|
+
const reservedFieldName = (
|
|
345
|
+
name: string,
|
|
346
|
+
span: Span,
|
|
347
|
+
owner: string,
|
|
348
|
+
): boolean => {
|
|
349
|
+
if (
|
|
350
|
+
RESERVED_FIELD_NAMES.has(name) ||
|
|
351
|
+
RESERVED_FIELD_PATTERNS.some((pattern) => pattern.test(name))
|
|
352
|
+
) {
|
|
353
|
+
error(
|
|
354
|
+
span,
|
|
355
|
+
`${owner} names a field ${name}, but the compiler reserves that name for a generated field; pick another name`,
|
|
356
|
+
);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const parseAmount = (
|
|
363
|
+
entry: Entry | undefined,
|
|
364
|
+
owner: string,
|
|
365
|
+
): MoneyField | undefined => {
|
|
366
|
+
if (!entry) return undefined;
|
|
367
|
+
noQualifiers(entry, owner);
|
|
368
|
+
const value = entry.value;
|
|
369
|
+
if (
|
|
370
|
+
value.kind === "binding" &&
|
|
371
|
+
value.type.kind === "call" &&
|
|
372
|
+
value.type.callee.name === "money" &&
|
|
373
|
+
value.type.args.length === 1 &&
|
|
374
|
+
value.type.args[0]?.kind === "ident"
|
|
375
|
+
) {
|
|
376
|
+
const currency = value.type.args[0].name;
|
|
377
|
+
if (!CAMEL_CASE.test(value.name.name)) {
|
|
378
|
+
error(
|
|
379
|
+
value.name.span,
|
|
380
|
+
`the ${entry.key.name} field is a camelCase name like price; "${value.name.name}" is not`,
|
|
381
|
+
);
|
|
382
|
+
} else if (!CURRENCY.test(currency)) {
|
|
383
|
+
error(
|
|
384
|
+
value.type.args[0].span,
|
|
385
|
+
`"${currency}" is not a currency code; use three capital letters like SAR`,
|
|
386
|
+
);
|
|
387
|
+
} else if (!reservedFieldName(value.name.name, value.name.span, owner)) {
|
|
388
|
+
return { currency, name: value.name.name, origin: value.span };
|
|
389
|
+
}
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
error(
|
|
393
|
+
value.span,
|
|
394
|
+
`${owner} declares ${entry.key.name} as a typed field, like: ${entry.key.name}: price: money(SAR)`,
|
|
395
|
+
);
|
|
396
|
+
return undefined;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const parsePortEntry = (
|
|
400
|
+
entry: Entry | undefined,
|
|
401
|
+
owner: string,
|
|
402
|
+
role: string,
|
|
403
|
+
acceptsDeadline = false,
|
|
404
|
+
):
|
|
405
|
+
| {
|
|
406
|
+
readonly deadlineField?: string;
|
|
407
|
+
readonly origin: Span;
|
|
408
|
+
readonly port: string;
|
|
409
|
+
}
|
|
410
|
+
| undefined => {
|
|
411
|
+
if (!entry) return undefined;
|
|
412
|
+
noQualifiers(entry, owner);
|
|
413
|
+
const value = entry.value;
|
|
414
|
+
if (value.kind !== "port_ref") {
|
|
415
|
+
error(
|
|
416
|
+
value.span,
|
|
417
|
+
`${owner} needs a ${role} decision, like: ${entry.key.name}: port ${entry.key.name}_decided`,
|
|
418
|
+
);
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
if (value.within) {
|
|
422
|
+
error(
|
|
423
|
+
value.within.span,
|
|
424
|
+
`${entry.key.name} on ${owner} has no time window; only dispute accepts "within <duration>"`,
|
|
425
|
+
);
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
if (value.deadline && !acceptsDeadline) {
|
|
429
|
+
error(
|
|
430
|
+
value.deadline.span,
|
|
431
|
+
`${entry.key.name} on ${owner} has no date default; only a held payment's release falls back to a date`,
|
|
432
|
+
);
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
if (!portNames.has(value.name.name)) {
|
|
436
|
+
error(
|
|
437
|
+
value.name.span,
|
|
438
|
+
`${owner} decides ${role} through port ${value.name.name}, but no port with that name is declared`,
|
|
439
|
+
);
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
let deadlineField: string | undefined;
|
|
443
|
+
if (value.deadline) {
|
|
444
|
+
if (!CAMEL_CASE.test(value.deadline.name)) {
|
|
445
|
+
error(
|
|
446
|
+
value.deadline.span,
|
|
447
|
+
`at( ... ) names a camelCase date field, like at(releaseDueAt); "${value.deadline.name}" is not`,
|
|
448
|
+
);
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
if (reservedFieldName(value.deadline.name, value.deadline.span, owner)) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
deadlineField = value.deadline.name;
|
|
455
|
+
}
|
|
456
|
+
referencedPorts.add(value.name.name);
|
|
457
|
+
return {
|
|
458
|
+
...(deadlineField ? { deadlineField } : {}),
|
|
459
|
+
origin: value.span,
|
|
460
|
+
port: value.name.name,
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const parseDisputeEntry = (
|
|
465
|
+
entry: Entry | undefined,
|
|
466
|
+
owner: string,
|
|
467
|
+
):
|
|
468
|
+
| {
|
|
469
|
+
readonly origin: Span;
|
|
470
|
+
readonly port: string;
|
|
471
|
+
readonly window: { readonly days: number; readonly raw: string };
|
|
472
|
+
}
|
|
473
|
+
| undefined => {
|
|
474
|
+
if (!entry) return undefined;
|
|
475
|
+
noQualifiers(entry, owner);
|
|
476
|
+
const value = entry.value;
|
|
477
|
+
if (value.kind !== "port_ref") {
|
|
478
|
+
error(
|
|
479
|
+
value.span,
|
|
480
|
+
`${owner} needs one whole-trade dispute decision, like: dispute: port resolve_dispute within P14D`,
|
|
481
|
+
);
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
if (!portNames.has(value.name.name)) {
|
|
485
|
+
error(
|
|
486
|
+
value.name.span,
|
|
487
|
+
`${owner} disputes through port ${value.name.name}, but no port with that name is declared`,
|
|
488
|
+
);
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
if (!value.within) {
|
|
492
|
+
error(
|
|
493
|
+
value.span,
|
|
494
|
+
`${owner} dispute needs a fixed window, like: dispute: port ${value.name.name} within P14D`,
|
|
495
|
+
);
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
const raw = value.within.name;
|
|
499
|
+
const match = /^P([1-9]\d{0,3})([DW])$/.exec(raw);
|
|
500
|
+
if (raw !== "P0D" && !match) {
|
|
501
|
+
error(
|
|
502
|
+
value.within.span,
|
|
503
|
+
`dispute on ${owner} uses a fixed duration in days or weeks, like P14D; calendar months cannot define an exact money deadline`,
|
|
504
|
+
);
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
const magnitude = match ? Number(match[1]) : 0;
|
|
508
|
+
const days = match?.[2] === "W" ? magnitude * 7 : magnitude;
|
|
509
|
+
referencedPorts.add(value.name.name);
|
|
510
|
+
return {
|
|
511
|
+
origin: value.span,
|
|
512
|
+
port: value.name.name,
|
|
513
|
+
window: { days, raw },
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const parsePercentEntry = (
|
|
518
|
+
entry: Entry | undefined,
|
|
519
|
+
owner: string,
|
|
520
|
+
label: string,
|
|
521
|
+
): number | undefined => {
|
|
522
|
+
if (!entry) return undefined;
|
|
523
|
+
noQualifiers(entry, owner);
|
|
524
|
+
if (entry.value.kind !== "percent") {
|
|
525
|
+
error(entry.value.span, `${label} on ${owner} must be a percent`);
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
if (entry.value.bps >= TOTAL_BPS) {
|
|
529
|
+
error(
|
|
530
|
+
entry.value.span,
|
|
531
|
+
`a ${entry.value.raw}% ${label} consumes the whole amount; it stays under 100%`,
|
|
532
|
+
);
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
if (entry.value.bps === 0) {
|
|
536
|
+
warning(
|
|
537
|
+
entry.value.span,
|
|
538
|
+
`${label} on ${owner} is 0%; drop the entry if none is meant`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return entry.value.bps;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const parseFees = (
|
|
545
|
+
entry: Entry | undefined,
|
|
546
|
+
owner: string,
|
|
547
|
+
payer: string | undefined,
|
|
548
|
+
payee: string | undefined,
|
|
549
|
+
): FeeTerm[] => {
|
|
550
|
+
const fees: FeeTerm[] = [];
|
|
551
|
+
if (!entry) return fees;
|
|
552
|
+
noQualifiers(entry, owner);
|
|
553
|
+
if (entry.value.kind !== "block") {
|
|
554
|
+
error(
|
|
555
|
+
entry.value.span,
|
|
556
|
+
`fees is a block of party shares, like: fees { buyer: 1% }`,
|
|
557
|
+
);
|
|
558
|
+
return fees;
|
|
559
|
+
}
|
|
560
|
+
for (const fee of entry.value.entries) {
|
|
561
|
+
const bearer = fee.key.name;
|
|
562
|
+
if (bearer !== payer && bearer !== payee) {
|
|
563
|
+
error(
|
|
564
|
+
fee.key.span,
|
|
565
|
+
`fees are borne by this settlement's payer or payee; ${bearer} is neither`,
|
|
566
|
+
);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (fees.some((existing) => existing.bearer === bearer)) {
|
|
570
|
+
error(fee.key.span, `${owner} sets a fee for ${bearer} twice`);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (fee.value.kind !== "percent") {
|
|
574
|
+
error(fee.value.span, `the fee for ${bearer} must be a percent`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (fee.value.bps >= TOTAL_BPS) {
|
|
578
|
+
error(
|
|
579
|
+
fee.value.span,
|
|
580
|
+
`a ${fee.value.raw}% fee consumes the whole amount; fees stay under 100%`,
|
|
581
|
+
);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (fee.value.bps === 0) {
|
|
585
|
+
warning(
|
|
586
|
+
fee.value.span,
|
|
587
|
+
`the fee for ${bearer} is 0%; drop the entry if no fee is meant`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
fees.push({ bearer, bps: fee.value.bps, origin: fee.span });
|
|
591
|
+
}
|
|
592
|
+
return fees;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const parseSwapFees = (
|
|
596
|
+
entry: Entry | undefined,
|
|
597
|
+
owner: string,
|
|
598
|
+
sides: readonly [string | undefined, string | undefined],
|
|
599
|
+
): SwapFee[] => {
|
|
600
|
+
const fees: SwapFee[] = [];
|
|
601
|
+
if (!entry) return fees;
|
|
602
|
+
noQualifiers(entry, owner);
|
|
603
|
+
if (entry.value.kind !== "block") {
|
|
604
|
+
error(
|
|
605
|
+
entry.value.span,
|
|
606
|
+
`fees is a block of exact on-top money fields, like: fees { buyer: buyerFee: money(SAR) }`,
|
|
607
|
+
);
|
|
608
|
+
return fees;
|
|
609
|
+
}
|
|
610
|
+
for (const fee of entry.value.entries) {
|
|
611
|
+
const bearer = fee.key.name;
|
|
612
|
+
if (!sides.includes(bearer)) {
|
|
613
|
+
error(
|
|
614
|
+
fee.key.span,
|
|
615
|
+
`swap fees are borne by one of its two parties; ${bearer} is not in between`,
|
|
616
|
+
);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (fees.some((existing) => existing.bearer === bearer)) {
|
|
620
|
+
error(fee.key.span, `${owner} sets a fee for ${bearer} twice`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const amount = parseAmount(fee, `${owner} fee for ${bearer}`);
|
|
624
|
+
if (amount) fees.push({ amount, bearer });
|
|
625
|
+
}
|
|
626
|
+
return fees;
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* A percent split block whose shares must sum to exactly 100%. Recipients
|
|
631
|
+
* are constrained to `allowed`; `remainder_to` is accepted only when
|
|
632
|
+
* `remainderAllowed` (pooled_split), naming the share that carries the
|
|
633
|
+
* integer-division remainder.
|
|
634
|
+
*/
|
|
635
|
+
const parseSplitBlock = (
|
|
636
|
+
entry: Entry,
|
|
637
|
+
owner: string,
|
|
638
|
+
allowed: readonly string[],
|
|
639
|
+
allowedLabel: string,
|
|
640
|
+
remainderAllowed: boolean,
|
|
641
|
+
):
|
|
642
|
+
| { readonly remainderTo?: string; readonly shares: SplitShare[] }
|
|
643
|
+
| undefined => {
|
|
644
|
+
if (entry.value.kind !== "block") {
|
|
645
|
+
error(
|
|
646
|
+
entry.value.span,
|
|
647
|
+
`${entry.key.name} on ${owner} is a block of percent shares, like: { ${allowed[0] ?? "party"}: 100% }`,
|
|
648
|
+
);
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
const shares: SplitShare[] = [];
|
|
652
|
+
let remainderTo: { readonly name: string; readonly span: Span } | undefined;
|
|
653
|
+
let sum = 0;
|
|
654
|
+
let sound = true;
|
|
655
|
+
for (const share of entry.value.entries) {
|
|
656
|
+
const to = share.key.name;
|
|
657
|
+
if (to === "remainder_to") {
|
|
658
|
+
if (!remainderAllowed) {
|
|
659
|
+
error(
|
|
660
|
+
share.key.span,
|
|
661
|
+
`${owner} does not take remainder_to here; the first share carries the integer remainder`,
|
|
662
|
+
);
|
|
663
|
+
sound = false;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (share.value.kind !== "ident") {
|
|
667
|
+
error(share.value.span, `remainder_to names one of the recipients`);
|
|
668
|
+
sound = false;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (remainderTo) {
|
|
672
|
+
error(share.key.span, `${owner} sets remainder_to twice`);
|
|
673
|
+
sound = false;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
remainderTo = { name: share.value.name, span: share.value.span };
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!allowed.includes(to)) {
|
|
680
|
+
error(
|
|
681
|
+
share.key.span,
|
|
682
|
+
`the ${entry.key.name} split goes to ${allowedLabel}; ${to} is not among them`,
|
|
683
|
+
);
|
|
684
|
+
sound = false;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
referencedParties.add(to);
|
|
688
|
+
if (shares.some((existing) => existing.to === to)) {
|
|
689
|
+
error(share.key.span, `${owner} splits to ${to} twice`);
|
|
690
|
+
sound = false;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (share.value.kind !== "percent") {
|
|
694
|
+
error(share.value.span, `the share for ${to} must be a percent`);
|
|
695
|
+
sound = false;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
// A 0% share would lower to a zero-amount money movement, which the
|
|
699
|
+
// kernel refuses at runtime; a recipient who receives nothing simply
|
|
700
|
+
// leaves the block.
|
|
701
|
+
if (share.value.bps === 0) {
|
|
702
|
+
error(
|
|
703
|
+
share.value.span,
|
|
704
|
+
`the share for ${to} is 0%; drop ${to} from the split instead of giving it nothing`,
|
|
705
|
+
);
|
|
706
|
+
sound = false;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
shares.push({ bps: share.value.bps, origin: share.span, to });
|
|
710
|
+
sum += share.value.bps;
|
|
711
|
+
}
|
|
712
|
+
if (sound && sum !== TOTAL_BPS) {
|
|
713
|
+
error(
|
|
714
|
+
entry.value.span,
|
|
715
|
+
`the ${entry.key.name} split must account for exactly 100%; these shares total ${(sum / 100).toFixed(2).replace(/\.?0+$/, "")}%`,
|
|
716
|
+
);
|
|
717
|
+
sound = false;
|
|
718
|
+
}
|
|
719
|
+
if (
|
|
720
|
+
remainderTo &&
|
|
721
|
+
!shares.some((share) => share.to === remainderTo?.name)
|
|
722
|
+
) {
|
|
723
|
+
error(
|
|
724
|
+
remainderTo.span,
|
|
725
|
+
`remainder_to must name one of the split's recipients; ${remainderTo.name} holds no share`,
|
|
726
|
+
);
|
|
727
|
+
sound = false;
|
|
728
|
+
}
|
|
729
|
+
if (!sound || shares.length === 0) return undefined;
|
|
730
|
+
return {
|
|
731
|
+
...(remainderTo ? { remainderTo: remainderTo.name } : {}),
|
|
732
|
+
shares,
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const parseCancelPolicy = (
|
|
737
|
+
entry: Entry | undefined,
|
|
738
|
+
owner: string,
|
|
739
|
+
allowed: readonly string[],
|
|
740
|
+
allowedLabel: string,
|
|
741
|
+
): CancelPolicy | undefined => {
|
|
742
|
+
if (!entry) return undefined;
|
|
743
|
+
const qualifier = entry.qualifiers[0];
|
|
744
|
+
if (entry.qualifiers.length !== 1 || qualifier?.name !== "funded") {
|
|
745
|
+
error(
|
|
746
|
+
entry.span,
|
|
747
|
+
`on_cancel states the phase it covers; today that is on_cancel(funded)`,
|
|
748
|
+
);
|
|
749
|
+
return undefined;
|
|
750
|
+
}
|
|
751
|
+
const split = parseSplitBlock(entry, owner, allowed, allowedLabel, false);
|
|
752
|
+
if (!split) return undefined;
|
|
753
|
+
return { origin: entry.span, shares: split.shares, when: "funded" };
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const parseDateField = (
|
|
757
|
+
entry: Entry | undefined,
|
|
758
|
+
owner: string,
|
|
759
|
+
): string | undefined => {
|
|
760
|
+
if (!entry) return undefined;
|
|
761
|
+
noQualifiers(entry, owner);
|
|
762
|
+
if (entry.value.kind === "ident" && CAMEL_CASE.test(entry.value.name)) {
|
|
763
|
+
return reservedFieldName(entry.value.name, entry.value.span, owner)
|
|
764
|
+
? undefined
|
|
765
|
+
: entry.value.name;
|
|
766
|
+
}
|
|
767
|
+
error(
|
|
768
|
+
entry.value.span,
|
|
769
|
+
`${entry.key.name} on ${owner} names a camelCase date field, like: ${entry.key.name}: dueDate`,
|
|
770
|
+
);
|
|
771
|
+
return undefined;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const parseSchedule = (
|
|
775
|
+
entries: Map<string, Entry>,
|
|
776
|
+
owner: string,
|
|
777
|
+
origin: Span,
|
|
778
|
+
): ScheduleTerms | undefined => {
|
|
779
|
+
const countEntry = entries.get("count");
|
|
780
|
+
const everyEntry = entries.get("every");
|
|
781
|
+
let count: number | undefined;
|
|
782
|
+
if (countEntry) {
|
|
783
|
+
noQualifiers(countEntry, owner);
|
|
784
|
+
const value = countEntry.value;
|
|
785
|
+
const parsed = value.kind === "number" ? Number(value.raw) : Number.NaN;
|
|
786
|
+
if (Number.isInteger(parsed) && parsed >= 2 && parsed <= 12) {
|
|
787
|
+
count = parsed;
|
|
788
|
+
} else {
|
|
789
|
+
error(
|
|
790
|
+
value.span,
|
|
791
|
+
`count on ${owner} is a literal number of anchors between 2 and 12; the schedule stays finite by construction`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
let every: ScheduleTerms["every"] | undefined;
|
|
796
|
+
if (everyEntry) {
|
|
797
|
+
noQualifiers(everyEntry, owner);
|
|
798
|
+
const value = everyEntry.value;
|
|
799
|
+
const raw =
|
|
800
|
+
value.kind === "ident"
|
|
801
|
+
? value.name
|
|
802
|
+
: value.kind === "string"
|
|
803
|
+
? value.value
|
|
804
|
+
: undefined;
|
|
805
|
+
const match = raw ? /^P([1-9]\d{0,3})([DW])$/.exec(raw) : null;
|
|
806
|
+
if (match && raw) {
|
|
807
|
+
const magnitude = Number(match[1]);
|
|
808
|
+
every = {
|
|
809
|
+
days: match[2] === "W" ? magnitude * 7 : magnitude,
|
|
810
|
+
raw,
|
|
811
|
+
};
|
|
812
|
+
} else {
|
|
813
|
+
error(
|
|
814
|
+
value.span,
|
|
815
|
+
`every on ${owner} is a fixed duration in days or weeks, like P30D or P2W; calendar months drift and cannot anchor an exact schedule`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const firstDueField = parseDateField(entries.get("first_due"), owner);
|
|
820
|
+
if (count === undefined || !every || !firstDueField) return undefined;
|
|
821
|
+
return { count, every, firstDueField, origin };
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* An advance draws its repayment from exactly one source: its own finite
|
|
826
|
+
* schedule, or a carve out of the release of a hold the advanced party is
|
|
827
|
+
* already owed. Which hold that is resolves after every settlement is
|
|
828
|
+
* checked, so only the reference's shape is validated here.
|
|
829
|
+
*/
|
|
830
|
+
const parseAdvanceSource = (
|
|
831
|
+
entries: Map<string, Entry>,
|
|
832
|
+
owner: string,
|
|
833
|
+
origin: Span,
|
|
834
|
+
): AdvanceSource | undefined => {
|
|
835
|
+
const againstEntry = entries.get("against");
|
|
836
|
+
const scheduleKeys = SCHEDULE_KEYS.filter((key) => entries.has(key));
|
|
837
|
+
if (againstEntry && scheduleKeys.length > 0) {
|
|
838
|
+
error(
|
|
839
|
+
againstEntry.span,
|
|
840
|
+
`${owner} is repaid by a carve and by its own schedule (${scheduleKeys.join(", ")}); an advance draws on one source`,
|
|
841
|
+
);
|
|
842
|
+
return undefined;
|
|
843
|
+
}
|
|
844
|
+
if (!againstEntry) {
|
|
845
|
+
if (scheduleKeys.length === 0) {
|
|
846
|
+
error(
|
|
847
|
+
origin,
|
|
848
|
+
`${owner} needs a repayment source: against: <hold>.release carves it out of a hold's release, or count + every + first_due collects it on a schedule`,
|
|
849
|
+
);
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
for (const key of SCHEDULE_KEYS) {
|
|
853
|
+
if (!entries.has(key)) error(origin, `${owner} is missing ${key}`);
|
|
854
|
+
}
|
|
855
|
+
const schedule = parseSchedule(entries, owner, origin);
|
|
856
|
+
return schedule ? { kind: "schedule", schedule } : undefined;
|
|
857
|
+
}
|
|
858
|
+
noQualifiers(againstEntry, owner);
|
|
859
|
+
const value = againstEntry.value;
|
|
860
|
+
if (value.kind !== "settlement_ref") {
|
|
861
|
+
error(
|
|
862
|
+
value.span,
|
|
863
|
+
`${owner} draws against a held payment's release, like: against: retention.release`,
|
|
864
|
+
);
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
if (value.member.name !== "release") {
|
|
868
|
+
error(
|
|
869
|
+
value.member.span,
|
|
870
|
+
`an advance carves a hold's release; there is no exit named ${value.member.name} on ${value.owner.name} to draw against`,
|
|
871
|
+
);
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
return { kind: "carve", origin: value.span, settlement: value.owner.name };
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// --- Settlements: one checker per archetype ---------------------------------
|
|
878
|
+
for (const decl of settlementDecls) {
|
|
879
|
+
const archetype = decl.archetype.name;
|
|
880
|
+
if (!imported.has(archetype)) {
|
|
881
|
+
error(
|
|
882
|
+
decl.archetype.span,
|
|
883
|
+
(SETTLEMENT_ARCHETYPES as readonly string[]).includes(archetype)
|
|
884
|
+
? `${archetype} must be imported first: import { ${archetype} } from "settlement"`
|
|
885
|
+
: `there is no settlement archetype named ${archetype}`,
|
|
886
|
+
);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const owner = `settlement ${decl.name.name}`;
|
|
891
|
+
const surface = ARCHETYPE_SURFACES[archetype as ArchetypeName];
|
|
892
|
+
const entries = entriesOf(decl.body, surface.keys, owner);
|
|
893
|
+
for (const required of surface.required) {
|
|
894
|
+
if (!entries.has(required)) {
|
|
895
|
+
error(decl.span, `${owner} is missing ${required}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const party = (key: string, role: string): string | undefined => {
|
|
899
|
+
const entry = entries.get(key);
|
|
900
|
+
if (!entry) return undefined;
|
|
901
|
+
noQualifiers(entry, owner);
|
|
902
|
+
return partyRef(entry.value, `${owner} ${role}`);
|
|
903
|
+
};
|
|
904
|
+
const distinct = (
|
|
905
|
+
left: string | undefined,
|
|
906
|
+
right: string | undefined,
|
|
907
|
+
message: string,
|
|
908
|
+
): boolean => {
|
|
909
|
+
if (left && right && left === right) {
|
|
910
|
+
error(decl.span, `${owner} ${message}`);
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
return true;
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
switch (archetype as ArchetypeName) {
|
|
917
|
+
case "swap": {
|
|
918
|
+
const betweenEntry = entries.get("between");
|
|
919
|
+
let sideA: string | undefined;
|
|
920
|
+
let sideB: string | undefined;
|
|
921
|
+
if (betweenEntry) {
|
|
922
|
+
noQualifiers(betweenEntry, owner);
|
|
923
|
+
if (
|
|
924
|
+
betweenEntry.value.kind !== "list" ||
|
|
925
|
+
betweenEntry.value.items.length !== 2
|
|
926
|
+
) {
|
|
927
|
+
error(
|
|
928
|
+
betweenEntry.value.span,
|
|
929
|
+
`${owner} swaps between exactly two parties, like: between: [buyer, seller]`,
|
|
930
|
+
);
|
|
931
|
+
} else {
|
|
932
|
+
sideA = partyRef(
|
|
933
|
+
betweenEntry.value.items[0] as Expr,
|
|
934
|
+
`${owner} first side`,
|
|
935
|
+
);
|
|
936
|
+
sideB = partyRef(
|
|
937
|
+
betweenEntry.value.items[1] as Expr,
|
|
938
|
+
`${owner} second side`,
|
|
939
|
+
);
|
|
940
|
+
distinct(
|
|
941
|
+
sideA,
|
|
942
|
+
sideB,
|
|
943
|
+
`names ${sideA} on both sides; the two swap parties must differ`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const amountByParty = new Map<string, MoneyField>();
|
|
949
|
+
const amountsEntry = entries.get("amounts");
|
|
950
|
+
if (amountsEntry) {
|
|
951
|
+
noQualifiers(amountsEntry, owner);
|
|
952
|
+
if (amountsEntry.value.kind !== "block") {
|
|
953
|
+
error(
|
|
954
|
+
amountsEntry.value.span,
|
|
955
|
+
`${owner} amounts is a two-party block, like: amounts { buyer: buyerPays: money(SAR), seller: sellerPays: money(SAR) }`,
|
|
956
|
+
);
|
|
957
|
+
} else {
|
|
958
|
+
for (const amountEntry of amountsEntry.value.entries) {
|
|
959
|
+
const partyName = amountEntry.key.name;
|
|
960
|
+
if (partyName !== sideA && partyName !== sideB) {
|
|
961
|
+
error(
|
|
962
|
+
amountEntry.key.span,
|
|
963
|
+
`${owner} amount belongs to one of its two parties; ${partyName} is not in between`,
|
|
964
|
+
);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (amountByParty.has(partyName)) {
|
|
968
|
+
error(
|
|
969
|
+
amountEntry.key.span,
|
|
970
|
+
`${owner} declares more than one amount for ${partyName}`,
|
|
971
|
+
);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const amount = parseAmount(amountEntry, owner);
|
|
975
|
+
if (amount) amountByParty.set(partyName, amount);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const sideAAmount = sideA ? amountByParty.get(sideA) : undefined;
|
|
980
|
+
const sideBAmount = sideB ? amountByParty.get(sideB) : undefined;
|
|
981
|
+
if (sideA && !sideAAmount) {
|
|
982
|
+
error(
|
|
983
|
+
amountsEntry?.span ?? decl.span,
|
|
984
|
+
`${owner} needs exactly one funded amount for ${sideA}`,
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (sideB && !sideBAmount) {
|
|
988
|
+
error(
|
|
989
|
+
amountsEntry?.span ?? decl.span,
|
|
990
|
+
`${owner} needs exactly one funded amount for ${sideB}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
if (
|
|
994
|
+
sideAAmount &&
|
|
995
|
+
sideBAmount &&
|
|
996
|
+
sideAAmount.currency !== sideBAmount.currency
|
|
997
|
+
) {
|
|
998
|
+
error(
|
|
999
|
+
amountsEntry?.span ?? decl.span,
|
|
1000
|
+
`${owner} cannot link ${sideAAmount.currency} and ${sideBAmount.currency} in one atomic ledger batch; both amounts need one currency`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const release = parsePortEntry(
|
|
1005
|
+
entries.get("release"),
|
|
1006
|
+
owner,
|
|
1007
|
+
"whole-trade release",
|
|
1008
|
+
);
|
|
1009
|
+
const dispute = parseDisputeEntry(entries.get("dispute"), owner);
|
|
1010
|
+
const fees = parseSwapFees(entries.get("fees"), owner, [sideA, sideB]);
|
|
1011
|
+
const swapAmounts = [sideAAmount, sideBAmount].filter(
|
|
1012
|
+
(amount): amount is MoneyField => amount !== undefined,
|
|
1013
|
+
);
|
|
1014
|
+
for (const fee of fees) {
|
|
1015
|
+
if (
|
|
1016
|
+
swapAmounts.some((amount) => amount.name === fee.amount.name) ||
|
|
1017
|
+
fees.some(
|
|
1018
|
+
(other) => other !== fee && other.amount.name === fee.amount.name,
|
|
1019
|
+
)
|
|
1020
|
+
) {
|
|
1021
|
+
error(
|
|
1022
|
+
fee.amount.origin,
|
|
1023
|
+
`${owner} fee field ${fee.amount.name} must be distinct from both principal and fee fields`,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
if (sideAAmount && fee.amount.currency !== sideAAmount.currency) {
|
|
1027
|
+
error(
|
|
1028
|
+
fee.amount.origin,
|
|
1029
|
+
`${owner} fee field ${fee.amount.name} must use the trade currency ${sideAAmount.currency}`,
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (
|
|
1034
|
+
sideA &&
|
|
1035
|
+
sideB &&
|
|
1036
|
+
sideAAmount &&
|
|
1037
|
+
sideBAmount &&
|
|
1038
|
+
sideAAmount.currency === sideBAmount.currency &&
|
|
1039
|
+
release
|
|
1040
|
+
) {
|
|
1041
|
+
const sideAFee = fees.find((fee) => fee.bearer === sideA);
|
|
1042
|
+
const sideBFee = fees.find((fee) => fee.bearer === sideB);
|
|
1043
|
+
settlements.push({
|
|
1044
|
+
archetype: "swap",
|
|
1045
|
+
...(dispute ? { dispute } : {}),
|
|
1046
|
+
name: decl.name.name,
|
|
1047
|
+
origin: decl.span,
|
|
1048
|
+
release,
|
|
1049
|
+
sides: [
|
|
1050
|
+
{
|
|
1051
|
+
amount: sideAAmount,
|
|
1052
|
+
...(sideAFee ? { fee: sideAFee } : {}),
|
|
1053
|
+
party: sideA,
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
amount: sideBAmount,
|
|
1057
|
+
...(sideBFee ? { fee: sideBFee } : {}),
|
|
1058
|
+
party: sideB,
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
break;
|
|
1064
|
+
}
|
|
1065
|
+
case "held_payment": {
|
|
1066
|
+
const payer = party("payer", "payer");
|
|
1067
|
+
const payee = party("payee", "payee");
|
|
1068
|
+
const sound = distinct(
|
|
1069
|
+
payer,
|
|
1070
|
+
payee,
|
|
1071
|
+
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1072
|
+
);
|
|
1073
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1074
|
+
const release = parsePortEntry(
|
|
1075
|
+
entries.get("release"),
|
|
1076
|
+
owner,
|
|
1077
|
+
"release",
|
|
1078
|
+
true,
|
|
1079
|
+
);
|
|
1080
|
+
const fees = parseFees(entries.get("fees"), owner, payer, payee);
|
|
1081
|
+
const onCancel = parseCancelPolicy(
|
|
1082
|
+
entries.get("on_cancel"),
|
|
1083
|
+
owner,
|
|
1084
|
+
[payer, payee].filter((name): name is string => name !== undefined),
|
|
1085
|
+
"this settlement's payer or payee",
|
|
1086
|
+
);
|
|
1087
|
+
if (amount && release?.deadlineField === amount.name) {
|
|
1088
|
+
error(
|
|
1089
|
+
release.origin,
|
|
1090
|
+
`${owner} uses ${amount.name} as both the held amount and the release date field; they need distinct names`,
|
|
1091
|
+
);
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
if (payer && payee && amount && release && sound) {
|
|
1095
|
+
settlements.push({
|
|
1096
|
+
amount,
|
|
1097
|
+
archetype: "held_payment",
|
|
1098
|
+
fees,
|
|
1099
|
+
name: decl.name.name,
|
|
1100
|
+
...(onCancel ? { onCancel } : {}),
|
|
1101
|
+
origin: decl.span,
|
|
1102
|
+
payee,
|
|
1103
|
+
payer,
|
|
1104
|
+
release: { origin: release.origin, port: release.port },
|
|
1105
|
+
...(release.deadlineField
|
|
1106
|
+
? { releaseDeadlineField: release.deadlineField }
|
|
1107
|
+
: {}),
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
case "instant_transfer": {
|
|
1113
|
+
const payer = party("payer", "payer");
|
|
1114
|
+
const payee = party("payee", "payee");
|
|
1115
|
+
const sound = distinct(
|
|
1116
|
+
payer,
|
|
1117
|
+
payee,
|
|
1118
|
+
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1119
|
+
);
|
|
1120
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1121
|
+
const fees = parseFees(entries.get("fees"), owner, payer, payee);
|
|
1122
|
+
if (payer && payee && amount && sound) {
|
|
1123
|
+
settlements.push({
|
|
1124
|
+
amount,
|
|
1125
|
+
archetype: "instant_transfer",
|
|
1126
|
+
fees,
|
|
1127
|
+
name: decl.name.name,
|
|
1128
|
+
origin: decl.span,
|
|
1129
|
+
payee,
|
|
1130
|
+
payer,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
case "premium_forward": {
|
|
1136
|
+
const payer = party("payer", "payer");
|
|
1137
|
+
const carrier = party("carrier", "carrier");
|
|
1138
|
+
const sound = distinct(
|
|
1139
|
+
payer,
|
|
1140
|
+
carrier,
|
|
1141
|
+
`forwards the premium from ${payer} to ${payer}; payer and carrier must differ`,
|
|
1142
|
+
);
|
|
1143
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1144
|
+
const bind = parsePortEntry(entries.get("bind"), owner, "binding");
|
|
1145
|
+
const commissionBps =
|
|
1146
|
+
parsePercentEntry(entries.get("commission"), owner, "commission") ??
|
|
1147
|
+
0;
|
|
1148
|
+
const onCancel = parseCancelPolicy(
|
|
1149
|
+
entries.get("on_cancel"),
|
|
1150
|
+
owner,
|
|
1151
|
+
[payer, carrier].filter((name): name is string => name !== undefined),
|
|
1152
|
+
"this settlement's payer or carrier",
|
|
1153
|
+
);
|
|
1154
|
+
if (payer && carrier && amount && bind && sound) {
|
|
1155
|
+
settlements.push({
|
|
1156
|
+
amount,
|
|
1157
|
+
archetype: "premium_forward",
|
|
1158
|
+
bind,
|
|
1159
|
+
carrier,
|
|
1160
|
+
commissionBps,
|
|
1161
|
+
name: decl.name.name,
|
|
1162
|
+
...(onCancel ? { onCancel } : {}),
|
|
1163
|
+
origin: decl.span,
|
|
1164
|
+
payer,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
case "deposit": {
|
|
1170
|
+
const payer = party("payer", "payer");
|
|
1171
|
+
const holder = party("holder", "holder");
|
|
1172
|
+
const sound = distinct(
|
|
1173
|
+
payer,
|
|
1174
|
+
holder,
|
|
1175
|
+
`holds ${payer}'s deposit for ${payer}; payer and holder must differ`,
|
|
1176
|
+
);
|
|
1177
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1178
|
+
const claim = parsePortEntry(entries.get("claim"), owner, "claim");
|
|
1179
|
+
const returnPort = parsePortEntry(
|
|
1180
|
+
entries.get("return"),
|
|
1181
|
+
owner,
|
|
1182
|
+
"return",
|
|
1183
|
+
);
|
|
1184
|
+
if (claim && returnPort && claim.port === returnPort.port) {
|
|
1185
|
+
error(
|
|
1186
|
+
returnPort.origin,
|
|
1187
|
+
`${owner} claims and returns through the same port ${claim.port}; the two exits need their own ports`,
|
|
1188
|
+
);
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
if (payer && holder && amount && claim && returnPort && sound) {
|
|
1192
|
+
settlements.push({
|
|
1193
|
+
amount,
|
|
1194
|
+
archetype: "deposit",
|
|
1195
|
+
claim,
|
|
1196
|
+
holder,
|
|
1197
|
+
name: decl.name.name,
|
|
1198
|
+
origin: decl.span,
|
|
1199
|
+
payer,
|
|
1200
|
+
return: returnPort,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
case "scheduled": {
|
|
1206
|
+
const payer = party("payer", "payer");
|
|
1207
|
+
const payee = party("payee", "payee");
|
|
1208
|
+
const sound = distinct(
|
|
1209
|
+
payer,
|
|
1210
|
+
payee,
|
|
1211
|
+
`pays ${payer} from ${payer}; payer and payee must differ`,
|
|
1212
|
+
);
|
|
1213
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1214
|
+
const schedule = parseSchedule(entries, owner, decl.span);
|
|
1215
|
+
if (amount && schedule && amount.name === schedule.firstDueField) {
|
|
1216
|
+
error(
|
|
1217
|
+
schedule.origin,
|
|
1218
|
+
`${owner} uses ${amount.name} as both the amount and the first_due date field; they need distinct names`,
|
|
1219
|
+
);
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
if (payer && payee && amount && schedule && sound) {
|
|
1223
|
+
settlements.push({
|
|
1224
|
+
amount,
|
|
1225
|
+
archetype: "scheduled",
|
|
1226
|
+
name: decl.name.name,
|
|
1227
|
+
origin: decl.span,
|
|
1228
|
+
payee,
|
|
1229
|
+
payer,
|
|
1230
|
+
schedule,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
case "advance": {
|
|
1236
|
+
const funder = party("funder", "funder");
|
|
1237
|
+
const advanced = party("to", "advanced party");
|
|
1238
|
+
const sound = distinct(
|
|
1239
|
+
funder,
|
|
1240
|
+
advanced,
|
|
1241
|
+
`advances ${funder} their own money; funder and advanced party must differ`,
|
|
1242
|
+
);
|
|
1243
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1244
|
+
const feeBps = parsePercentEntry(entries.get("fee"), owner, "fee") ?? 0;
|
|
1245
|
+
const source = parseAdvanceSource(entries, owner, decl.span);
|
|
1246
|
+
if (
|
|
1247
|
+
amount &&
|
|
1248
|
+
source?.kind === "schedule" &&
|
|
1249
|
+
amount.name === source.schedule.firstDueField
|
|
1250
|
+
) {
|
|
1251
|
+
error(
|
|
1252
|
+
source.schedule.origin,
|
|
1253
|
+
`${owner} uses ${amount.name} as both the amount and the first_due date field; they need distinct names`,
|
|
1254
|
+
);
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
if (funder && advanced && amount && source && sound) {
|
|
1258
|
+
settlements.push({
|
|
1259
|
+
advanced,
|
|
1260
|
+
amount,
|
|
1261
|
+
archetype: "advance",
|
|
1262
|
+
feeBps,
|
|
1263
|
+
funder,
|
|
1264
|
+
name: decl.name.name,
|
|
1265
|
+
origin: decl.span,
|
|
1266
|
+
source,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
break;
|
|
1270
|
+
}
|
|
1271
|
+
case "metered": {
|
|
1272
|
+
const payer = party("payer", "payer");
|
|
1273
|
+
const payee = party("payee", "payee");
|
|
1274
|
+
const sound = distinct(
|
|
1275
|
+
payer,
|
|
1276
|
+
payee,
|
|
1277
|
+
`meters ${payer} against ${payer}; payer and payee must differ`,
|
|
1278
|
+
);
|
|
1279
|
+
const closeByField = parseDateField(entries.get("close_by"), owner);
|
|
1280
|
+
const rates: MeterRate[] = [];
|
|
1281
|
+
const ratesEntry = entries.get("rates");
|
|
1282
|
+
if (ratesEntry) {
|
|
1283
|
+
noQualifiers(ratesEntry, owner);
|
|
1284
|
+
if (ratesEntry.value.kind !== "block") {
|
|
1285
|
+
error(
|
|
1286
|
+
ratesEntry.value.span,
|
|
1287
|
+
`rates is a block of per-unit prices, like: rates { api_call: unitFee: money(SAR) }`,
|
|
1288
|
+
);
|
|
1289
|
+
} else {
|
|
1290
|
+
for (const rate of ratesEntry.value.entries) {
|
|
1291
|
+
if (!SNAKE_CASE.test(rate.key.name)) {
|
|
1292
|
+
error(
|
|
1293
|
+
rate.key.span,
|
|
1294
|
+
`meter names are snake_case; "${rate.key.name}" is not`,
|
|
1295
|
+
);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (rates.some((existing) => existing.meter === rate.key.name)) {
|
|
1299
|
+
error(
|
|
1300
|
+
rate.key.span,
|
|
1301
|
+
`${owner} prices meter ${rate.key.name} twice`,
|
|
1302
|
+
);
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
const field = parseAmount(rate, owner);
|
|
1306
|
+
if (!field) continue;
|
|
1307
|
+
if (
|
|
1308
|
+
rates.some((existing) => existing.field.name === field.name)
|
|
1309
|
+
) {
|
|
1310
|
+
error(
|
|
1311
|
+
field.origin,
|
|
1312
|
+
`${owner} reuses the field ${field.name} for two meters`,
|
|
1313
|
+
);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
const currency = rates[0]?.field.currency;
|
|
1317
|
+
if (currency && currency !== field.currency) {
|
|
1318
|
+
error(
|
|
1319
|
+
field.origin,
|
|
1320
|
+
`${owner} mixes ${currency} and ${field.currency}; one settlement meters in one currency`,
|
|
1321
|
+
);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
rates.push({ field, meter: rate.key.name, origin: rate.span });
|
|
1325
|
+
}
|
|
1326
|
+
if (ratesEntry.value.entries.length === 0) {
|
|
1327
|
+
error(ratesEntry.value.span, `${owner} prices no meters`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (
|
|
1332
|
+
closeByField &&
|
|
1333
|
+
rates.some((rate) => rate.field.name === closeByField)
|
|
1334
|
+
) {
|
|
1335
|
+
error(
|
|
1336
|
+
decl.span,
|
|
1337
|
+
`${owner} uses ${closeByField} as both a rate field and the close_by date field; they need distinct names`,
|
|
1338
|
+
);
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
1341
|
+
if (payer && payee && closeByField && rates.length > 0 && sound) {
|
|
1342
|
+
settlements.push({
|
|
1343
|
+
archetype: "metered",
|
|
1344
|
+
closeByField,
|
|
1345
|
+
name: decl.name.name,
|
|
1346
|
+
origin: decl.span,
|
|
1347
|
+
payee,
|
|
1348
|
+
payer,
|
|
1349
|
+
rates,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
case "pooled_split": {
|
|
1355
|
+
const payer = party("payer", "payer");
|
|
1356
|
+
const amount = parseAmount(entries.get("amount"), owner);
|
|
1357
|
+
const distributeDueField = parseDateField(
|
|
1358
|
+
entries.get("payout_due"),
|
|
1359
|
+
owner,
|
|
1360
|
+
);
|
|
1361
|
+
const splitEntry = entries.get("split");
|
|
1362
|
+
const recipients = [...parties.keys()].filter((name) => name !== payer);
|
|
1363
|
+
const split = splitEntry
|
|
1364
|
+
? parseSplitBlock(
|
|
1365
|
+
splitEntry,
|
|
1366
|
+
owner,
|
|
1367
|
+
recipients,
|
|
1368
|
+
"declared parties other than the funder",
|
|
1369
|
+
true,
|
|
1370
|
+
)
|
|
1371
|
+
: undefined;
|
|
1372
|
+
if (split && split.shares.length < 2) {
|
|
1373
|
+
error(
|
|
1374
|
+
splitEntry!.value.span,
|
|
1375
|
+
`${owner} splits to a single recipient; a pool distributes between at least two`,
|
|
1376
|
+
);
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
// The lowering names each share field ${camelCase(party)}ShareAmount;
|
|
1380
|
+
// camelCasing is not injective (a_2b and a2b collide), so two shares
|
|
1381
|
+
// must never map onto one generated field.
|
|
1382
|
+
if (split) {
|
|
1383
|
+
const shareFieldOwners = new Map<string, string>();
|
|
1384
|
+
for (const share of split.shares) {
|
|
1385
|
+
const [head = "", ...rest] = share.to.split("_");
|
|
1386
|
+
const field = `${
|
|
1387
|
+
head +
|
|
1388
|
+
rest
|
|
1389
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
1390
|
+
.join("")
|
|
1391
|
+
}ShareAmount`;
|
|
1392
|
+
const other = shareFieldOwners.get(field);
|
|
1393
|
+
if (other) {
|
|
1394
|
+
error(
|
|
1395
|
+
share.origin,
|
|
1396
|
+
`${owner} splits to ${other} and ${share.to}, whose generated share fields both come out as ${field}; rename one party`,
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
shareFieldOwners.set(field, share.to);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (
|
|
1403
|
+
amount &&
|
|
1404
|
+
distributeDueField &&
|
|
1405
|
+
amount.name === distributeDueField
|
|
1406
|
+
) {
|
|
1407
|
+
error(
|
|
1408
|
+
decl.span,
|
|
1409
|
+
`${owner} uses ${amount.name} as both the amount and the payout_due date field; they need distinct names`,
|
|
1410
|
+
);
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
if (payer && amount && distributeDueField && split) {
|
|
1414
|
+
settlements.push({
|
|
1415
|
+
amount,
|
|
1416
|
+
archetype: "pooled_split",
|
|
1417
|
+
distributeDueField,
|
|
1418
|
+
name: decl.name.name,
|
|
1419
|
+
origin: decl.span,
|
|
1420
|
+
payer,
|
|
1421
|
+
remainderTo: split.remainderTo ?? split.shares[0]!.to,
|
|
1422
|
+
shares: split.shares,
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// --- Ports ------------------------------------------------------------------
|
|
1431
|
+
const ports: CheckedPort[] = [];
|
|
1432
|
+
for (const decl of portDecls) {
|
|
1433
|
+
const owner = `port ${decl.name.name}`;
|
|
1434
|
+
// The tenant's own backend is always the decider, so a port carries only
|
|
1435
|
+
// who may ask (allowed) and what the answer looks like (shape).
|
|
1436
|
+
const entries = entriesOf(decl.body, ["allowed", "shape"], owner);
|
|
1437
|
+
if (!entries.has("allowed")) {
|
|
1438
|
+
error(decl.span, `${owner} is missing allowed`);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const allowed: string[] = [];
|
|
1442
|
+
const allowedEntry = entries.get("allowed");
|
|
1443
|
+
if (allowedEntry) {
|
|
1444
|
+
noQualifiers(allowedEntry, owner);
|
|
1445
|
+
const value = allowedEntry.value;
|
|
1446
|
+
if (value.kind !== "list" || value.items.length === 0) {
|
|
1447
|
+
error(
|
|
1448
|
+
value.span,
|
|
1449
|
+
`allowed lists who may ask this question, like: allowed: [buyer]`,
|
|
1450
|
+
);
|
|
1451
|
+
} else {
|
|
1452
|
+
for (const item of value.items) {
|
|
1453
|
+
const party = partyRef(item, `${owner} allowed`);
|
|
1454
|
+
if (!party) continue;
|
|
1455
|
+
if (allowed.includes(party)) {
|
|
1456
|
+
error(item.span, `${owner} allows ${party} twice`);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
allowed.push(party);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const fields: PortField[] = [];
|
|
1465
|
+
const shapeEntry = entries.get("shape");
|
|
1466
|
+
if (shapeEntry) {
|
|
1467
|
+
noQualifiers(shapeEntry, owner);
|
|
1468
|
+
if (shapeEntry.value.kind !== "block") {
|
|
1469
|
+
error(
|
|
1470
|
+
shapeEntry.value.span,
|
|
1471
|
+
`shape is a block of typed fields, like: shape { vehicleId: id(vehicle) }`,
|
|
1472
|
+
);
|
|
1473
|
+
} else {
|
|
1474
|
+
for (const field of shapeEntry.value.entries) {
|
|
1475
|
+
if (!CAMEL_CASE.test(field.key.name)) {
|
|
1476
|
+
error(
|
|
1477
|
+
field.key.span,
|
|
1478
|
+
`shape fields are camelCase names; "${field.key.name}" is not`,
|
|
1479
|
+
);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
if (fields.some((existing) => existing.name === field.key.name)) {
|
|
1483
|
+
error(field.key.span, `${owner} declares ${field.key.name} twice`);
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const type = portFieldType(field.value);
|
|
1487
|
+
if (!type) {
|
|
1488
|
+
error(
|
|
1489
|
+
field.value.span,
|
|
1490
|
+
`${field.key.name} needs a type: id(<asset>), money(<CUR>), text, or date`,
|
|
1491
|
+
);
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
if (type.kind === "asset_id" && !assets.has(type.asset)) {
|
|
1495
|
+
error(
|
|
1496
|
+
field.value.span,
|
|
1497
|
+
`id(${type.asset}) points at an asset that is not declared`,
|
|
1498
|
+
);
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
if (type.kind === "money" && !CURRENCY.test(type.currency)) {
|
|
1502
|
+
error(
|
|
1503
|
+
field.value.span,
|
|
1504
|
+
`"${type.currency}" is not a currency code; use three capital letters like SAR`,
|
|
1505
|
+
);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
fields.push({ name: field.key.name, origin: field.span, type });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (allowed.length > 0) {
|
|
1514
|
+
ports.push({
|
|
1515
|
+
allowed,
|
|
1516
|
+
fields,
|
|
1517
|
+
name: decl.name.name,
|
|
1518
|
+
origin: decl.span,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const checkedPortByName = new Map(ports.map((port) => [port.name, port]));
|
|
1524
|
+
for (const settlement of settlements) {
|
|
1525
|
+
if (settlement.archetype !== "swap") continue;
|
|
1526
|
+
const parties = new Set(settlement.sides.map((side) => side.party));
|
|
1527
|
+
const decisions = [
|
|
1528
|
+
{ label: "release", portRef: settlement.release },
|
|
1529
|
+
...(settlement.dispute
|
|
1530
|
+
? [{ label: "dispute", portRef: settlement.dispute }]
|
|
1531
|
+
: []),
|
|
1532
|
+
];
|
|
1533
|
+
for (const decision of decisions) {
|
|
1534
|
+
const port = checkedPortByName.get(decision.portRef.port);
|
|
1535
|
+
if (!port) continue;
|
|
1536
|
+
for (const actor of port.allowed) {
|
|
1537
|
+
if (!parties.has(actor)) {
|
|
1538
|
+
error(
|
|
1539
|
+
port.origin,
|
|
1540
|
+
`swap ${settlement.name} ${decision.label} port ${port.name} allows ${actor}, but whole-trade decisions belong only to ${settlement.sides.map((side) => side.party).join(" or ")}`,
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// --- Carved advances: the hold each one draws against -----------------------
|
|
1548
|
+
// `against: retention.release` is the only cross-declaration reference in the
|
|
1549
|
+
// language, so it is the only place a settlement's terms are judged against
|
|
1550
|
+
// another's. Everything here resolves at check time; nothing is left for a
|
|
1551
|
+
// caller to pick.
|
|
1552
|
+
const heldByName = new Map(
|
|
1553
|
+
settlements
|
|
1554
|
+
.filter((settlement) => settlement.archetype === "held_payment")
|
|
1555
|
+
.map((settlement) => [settlement.name, settlement]),
|
|
1556
|
+
);
|
|
1557
|
+
const carvedBy = new Map<string, string>();
|
|
1558
|
+
for (const settlement of settlements) {
|
|
1559
|
+
if (settlement.archetype !== "advance") continue;
|
|
1560
|
+
if (settlement.source.kind !== "carve") continue;
|
|
1561
|
+
const owner = `settlement ${settlement.name}`;
|
|
1562
|
+
const target = settlement.source.settlement;
|
|
1563
|
+
const origin = settlement.source.origin;
|
|
1564
|
+
const targetDecl = settlementDecls.find(
|
|
1565
|
+
(decl) => decl.name.name === target,
|
|
1566
|
+
);
|
|
1567
|
+
if (!targetDecl) {
|
|
1568
|
+
const other = declared.get(target);
|
|
1569
|
+
error(
|
|
1570
|
+
origin,
|
|
1571
|
+
other
|
|
1572
|
+
? `${owner} draws against ${target}, which is a ${other.kind}; an advance carves a held payment's release`
|
|
1573
|
+
: `${owner} draws against ${target}, but no settlement with that name is declared`,
|
|
1574
|
+
);
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
if (targetDecl.archetype.name !== "held_payment") {
|
|
1578
|
+
error(
|
|
1579
|
+
origin,
|
|
1580
|
+
`${owner} draws against ${target}, which is a ${targetDecl.archetype.name}; only a held payment has a release to carve`,
|
|
1581
|
+
);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
const hold = heldByName.get(target);
|
|
1585
|
+
// The hold's own body failed to check and already said why; a second
|
|
1586
|
+
// complaint about it here would only bury the first.
|
|
1587
|
+
if (!hold) continue;
|
|
1588
|
+
|
|
1589
|
+
if (hold.payee !== settlement.advanced) {
|
|
1590
|
+
error(
|
|
1591
|
+
origin,
|
|
1592
|
+
`${owner} advances ${settlement.advanced}, but ${target} releases to ${hold.payee}; an advance carves the release of the party it finances`,
|
|
1593
|
+
);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
if (hold.payer === settlement.funder) {
|
|
1597
|
+
error(
|
|
1598
|
+
origin,
|
|
1599
|
+
`${settlement.funder} funds ${target} and finances it too; the party who pays the hold cannot be the funder its release repays`,
|
|
1600
|
+
);
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
const first = carvedBy.get(target);
|
|
1604
|
+
if (first) {
|
|
1605
|
+
error(
|
|
1606
|
+
origin,
|
|
1607
|
+
`${owner} and settlement ${first} both draw against ${target}; one release repays one advance`,
|
|
1608
|
+
);
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
carvedBy.set(target, settlement.name);
|
|
1612
|
+
|
|
1613
|
+
// The hold's release is the advance's only repayment, so every exit that
|
|
1614
|
+
// is not that release leaves the funder unpaid. Cancellation is the exit
|
|
1615
|
+
// the author chose to declare, so an uncovered one is an error; the
|
|
1616
|
+
// pre-funding abandonment every hold carries is a lint.
|
|
1617
|
+
const recourse = settlements.some(
|
|
1618
|
+
(other) =>
|
|
1619
|
+
other.archetype === "scheduled" &&
|
|
1620
|
+
other.payer === settlement.advanced &&
|
|
1621
|
+
other.payee === settlement.funder,
|
|
1622
|
+
);
|
|
1623
|
+
if (recourse) continue;
|
|
1624
|
+
const repayment = `add a scheduled settlement collecting from the ${settlement.advanced.replaceAll("_", " ")} to the ${settlement.funder.replaceAll("_", " ")}`;
|
|
1625
|
+
if (hold.onCancel) {
|
|
1626
|
+
error(
|
|
1627
|
+
origin,
|
|
1628
|
+
`${owner} has no repayment path when ${target} is refunded instead of released; ${repayment}`,
|
|
1629
|
+
);
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
warning(
|
|
1633
|
+
origin,
|
|
1634
|
+
`${owner} has no repayment path if ${target} is abandoned before it funds; ${repayment}`,
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// --- Whole-program lint -----------------------------------------------------
|
|
1639
|
+
for (const [name, party] of parties) {
|
|
1640
|
+
if (!referencedParties.has(name)) {
|
|
1641
|
+
warning(
|
|
1642
|
+
party.origin,
|
|
1643
|
+
`party ${name} is declared but no settlement or port involves them`,
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
for (const decl of portDecls) {
|
|
1648
|
+
if (portNames.has(decl.name.name) && !referencedPorts.has(decl.name.name)) {
|
|
1649
|
+
warning(
|
|
1650
|
+
decl.span,
|
|
1651
|
+
`port ${decl.name.name} is declared but nothing releases through it`,
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
for (const [name, span] of imported) {
|
|
1656
|
+
const used = settlementDecls.some((decl) => decl.archetype.name === name);
|
|
1657
|
+
if (!used) {
|
|
1658
|
+
warning(span, `${name} is imported but never instantiated`);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const hasErrors = diagnostics.some(
|
|
1663
|
+
(diagnostic) => diagnostic.severity === "error",
|
|
1664
|
+
);
|
|
1665
|
+
if (hasErrors || !header) return { diagnostics };
|
|
1666
|
+
return {
|
|
1667
|
+
diagnostics,
|
|
1668
|
+
program: {
|
|
1669
|
+
assets: [...assets.values()],
|
|
1670
|
+
name: header.name.name,
|
|
1671
|
+
parties: [...parties.values()],
|
|
1672
|
+
ports,
|
|
1673
|
+
settlements,
|
|
1674
|
+
title: header.title?.value ?? titleize(header.name.name),
|
|
1675
|
+
},
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function portFieldType(expr: Expr): PortFieldType | undefined {
|
|
1680
|
+
if (expr.kind === "ident" && expr.name === "text") return { kind: "text" };
|
|
1681
|
+
if (expr.kind === "ident" && expr.name === "date") return { kind: "date" };
|
|
1682
|
+
if (
|
|
1683
|
+
expr.kind === "call" &&
|
|
1684
|
+
expr.args.length === 1 &&
|
|
1685
|
+
expr.args[0]?.kind === "ident"
|
|
1686
|
+
) {
|
|
1687
|
+
const argument = expr.args[0].name;
|
|
1688
|
+
if (expr.callee.name === "id") return { asset: argument, kind: "asset_id" };
|
|
1689
|
+
if (expr.callee.name === "money") {
|
|
1690
|
+
return { currency: argument, kind: "money" };
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return undefined;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function titleize(snake: string): string {
|
|
1697
|
+
const spaced = snake.replaceAll("_", " ");
|
|
1698
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
1699
|
+
}
|