@mandateops/audit 0.2.0 → 0.4.2
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/dist/index.d.ts +44 -3
- package/dist/index.js +75 -14
- package/dist/providers/fiat.d.ts +16 -1
- package/dist/providers/fiat.js +28 -2
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/types.d.ts +9 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { CapacityReading, HistoryReading, ReadingProfile } from "./reading.js";
|
|
2
2
|
import type { ProviderWire, StagedWire } from "./providers/index.js";
|
|
3
|
-
export
|
|
4
|
-
export type { CheckoutTerms, CurrencyTerms, BridgeTerms, FireblocksTerms, LedgerTerms, MollieTerms, OwnAccountTerms, ProviderWire, SourcedTerms, StagedWire, TermDefaults, WiseRoutes, WiseTerms, } from "./providers/index.js";
|
|
3
|
+
export * from "./providers/index.js";
|
|
5
4
|
export { parseCapacityReading, parseHistoryReading, ReadingError, requireHistoryFits, } from "./reading.js";
|
|
6
5
|
export type { AttemptEnd, AttemptResult, CapacityReading, Flow, HistoryEvent, HistoryReading, Instruction, MinorUnits, Payout, PayoutState, ReadingProfile, HistoryConsistency, CapacityConsistency, } from "./reading.js";
|
|
7
6
|
/**
|
|
@@ -181,6 +180,12 @@ export type SealedResult = Readonly<{
|
|
|
181
180
|
reasons?: readonly unknown[];
|
|
182
181
|
}>;
|
|
183
182
|
record_published: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Why the run cannot pass whatever its properties say, such as
|
|
185
|
+
* `overlap_not_established` for a race the host could not show in flight
|
|
186
|
+
* together, or `capacity_exceeded` for competitors accepted past their limit.
|
|
187
|
+
*/
|
|
188
|
+
reason?: string;
|
|
184
189
|
}>;
|
|
185
190
|
/** A deployment that may have outlived its run, and what removes it. */
|
|
186
191
|
export type TeardownFailure = Readonly<{
|
|
@@ -207,12 +212,28 @@ type Credentials = Readonly<Record<ServiceRole, string>>;
|
|
|
207
212
|
* The session protocol this SDK speaks. The host announces its own when
|
|
208
213
|
* ready; the two must match, as they do for an SDK shipped with its CLI.
|
|
209
214
|
*/
|
|
210
|
-
export declare const SESSION_PROTOCOL =
|
|
215
|
+
export declare const SESSION_PROTOCOL = 3;
|
|
211
216
|
/** Why a host of another protocol is refused, and how to fix it. */
|
|
212
217
|
export declare function protocolMismatch(announced: number | undefined): string;
|
|
213
218
|
/** A sealed result, checked before anything reads it. */
|
|
214
219
|
export declare function parseResult(value: unknown): SealedResult;
|
|
215
220
|
export type LossMode = "cut_connection" | "hold_response" | "crash_process";
|
|
221
|
+
/** What the host's gate saw of one race. */
|
|
222
|
+
export type RaceReport = Readonly<{
|
|
223
|
+
parties: number;
|
|
224
|
+
/** Every party arrived and all were in flight at the backend at once. */
|
|
225
|
+
overlap_established: boolean;
|
|
226
|
+
/** Why overlap was not established, when it was not. */
|
|
227
|
+
reason?: string;
|
|
228
|
+
}>;
|
|
229
|
+
/** Each party's own result, and whether the race really overlapped. */
|
|
230
|
+
export type RaceResult<T> = Readonly<{
|
|
231
|
+
settled: readonly PromiseSettledResult<T>[];
|
|
232
|
+
report: RaceReport;
|
|
233
|
+
}>;
|
|
234
|
+
/** Fewest and most parties one race holds; the host enforces the same. */
|
|
235
|
+
export declare const MIN_RACE_PARTIES = 2;
|
|
236
|
+
export declare const MAX_RACE_PARTIES = 8;
|
|
216
237
|
export type Fixture = Readonly<{
|
|
217
238
|
/** The deployed service's base URL. Optional convenience for `fetch`. */
|
|
218
239
|
app: string;
|
|
@@ -220,6 +241,26 @@ export type Fixture = Readonly<{
|
|
|
220
241
|
credentials: Credentials;
|
|
221
242
|
provider: Readonly<{
|
|
222
243
|
expectPayment: (expected: ExpectedPayment) => Promise<Authorised>;
|
|
244
|
+
/**
|
|
245
|
+
* Declares payments that compete for the one capacity limit, in place of
|
|
246
|
+
* `expectPayment`. Bind each one the backend accepted; the first bound is
|
|
247
|
+
* the payout judged, and the accepted ones together must stay within
|
|
248
|
+
* `shared.limitMinor`. `capacityLimitMinor` stays the limit of one
|
|
249
|
+
* payout's own capacity readings. A competition must be raced through `concurrency.race`.
|
|
250
|
+
*/
|
|
251
|
+
expectCompetingPayments: (expected: readonly ExpectedPayment[], shared: Readonly<{
|
|
252
|
+
limitMinor: string;
|
|
253
|
+
}>) => Promise<readonly Authorised[]>;
|
|
254
|
+
}>;
|
|
255
|
+
concurrency: Readonly<{
|
|
256
|
+
/**
|
|
257
|
+
* Runs `parties` submissions at once through a host gate in front of the
|
|
258
|
+
* backend: each `submit` gets the gate's base URL in place of `app`. The
|
|
259
|
+
* gate releases the requests only when all have arrived, so they meet at
|
|
260
|
+
* the backend's reservation boundary together. A race the gate cannot
|
|
261
|
+
* show overlapped does not pass.
|
|
262
|
+
*/
|
|
263
|
+
race: <T>(parties: number, submit: (url: string, party: number) => Promise<T>) => Promise<RaceResult<T>>;
|
|
223
264
|
}>;
|
|
224
265
|
faults: Readonly<{
|
|
225
266
|
/**
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,8 @@ import { tmpdir } from "node:os";
|
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
14
14
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
15
|
import { parseCapacityReading, parseHistoryReading, requireHistoryFits, } from "./reading.js";
|
|
16
|
-
|
|
16
|
+
// Every provider format and its types, from the one list that names them.
|
|
17
|
+
export * from "./providers/index.js";
|
|
17
18
|
export { parseCapacityReading, parseHistoryReading, ReadingError, requireHistoryFits, } from "./reading.js";
|
|
18
19
|
const MINOR_UNITS = /^[1-9][0-9]*$/;
|
|
19
20
|
/**
|
|
@@ -75,7 +76,7 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
75
76
|
* The session protocol this SDK speaks. The host announces its own when
|
|
76
77
|
* ready; the two must match, as they do for an SDK shipped with its CLI.
|
|
77
78
|
*/
|
|
78
|
-
export const SESSION_PROTOCOL =
|
|
79
|
+
export const SESSION_PROTOCOL = 3;
|
|
79
80
|
/** Why a host of another protocol is refused, and how to fix it. */
|
|
80
81
|
export function protocolMismatch(announced) {
|
|
81
82
|
const host = announced === undefined
|
|
@@ -135,7 +136,8 @@ export function parseResult(value) {
|
|
|
135
136
|
Array.isArray(value["properties"]) &&
|
|
136
137
|
value["properties"].every(isDecision) &&
|
|
137
138
|
isVerdict(value["verdict"]) &&
|
|
138
|
-
typeof value["record_published"] === "boolean"
|
|
139
|
+
typeof value["record_published"] === "boolean" &&
|
|
140
|
+
(value["reason"] === undefined || typeof value["reason"] === "string");
|
|
139
141
|
if (!valid)
|
|
140
142
|
throw new TypeError("audit host sealed a result this SDK cannot read; the @mandateops/audit package and the mandateops CLI are probably different versions: add the SDK shipped with your CLI (its sdk/mandateops-audit-VERSION.tgz)");
|
|
141
143
|
return value;
|
|
@@ -225,6 +227,13 @@ class Channel {
|
|
|
225
227
|
return reply.body;
|
|
226
228
|
}
|
|
227
229
|
}
|
|
230
|
+
/** Fewest and most parties one race holds; the host enforces the same. */
|
|
231
|
+
export const MIN_RACE_PARTIES = 2;
|
|
232
|
+
export const MAX_RACE_PARTIES = 8;
|
|
233
|
+
const isRaceReport = (value) => isRecord(value) &&
|
|
234
|
+
typeof value["parties"] === "number" &&
|
|
235
|
+
typeof value["overlap_established"] === "boolean" &&
|
|
236
|
+
(value["reason"] === undefined || typeof value["reason"] === "string");
|
|
228
237
|
/**
|
|
229
238
|
* The whole run, for a test runner's own timeout: startup, the body, closing
|
|
230
239
|
* work on either side of the longest wait, and teardown, which may take as
|
|
@@ -402,7 +411,7 @@ async function session(config, body, channel, backend, credentials) {
|
|
|
402
411
|
}
|
|
403
412
|
};
|
|
404
413
|
const requireBound = (doing) => {
|
|
405
|
-
if (profile && authorised && providerPayment === undefined) {
|
|
414
|
+
if (profile && (authorised || competing) && providerPayment === undefined) {
|
|
406
415
|
throw new AuditUsageError(`${doing} under ${profile} needs bindProviderPayment first: adapters read by that identifier`);
|
|
407
416
|
}
|
|
408
417
|
};
|
|
@@ -413,6 +422,18 @@ async function session(config, body, channel, backend, credentials) {
|
|
|
413
422
|
throw new AuditUsageError(`${doing}: ${FAULT_STAGE_MEANS[fault]}`);
|
|
414
423
|
}
|
|
415
424
|
};
|
|
425
|
+
const authorisationOf = (expected) => ({
|
|
426
|
+
instruction: wireInstruction(expected),
|
|
427
|
+
capacity_limit: minorUnits(config.capacityLimitMinor),
|
|
428
|
+
provider: {
|
|
429
|
+
account: config.system.providerTerms.account,
|
|
430
|
+
operation: config.system.providerTerms.operation,
|
|
431
|
+
idempotency_scope: config.system.providerTerms.idempotencyScope,
|
|
432
|
+
},
|
|
433
|
+
...(expected.outcome ? { expected_outcome: expected.outcome } : {}),
|
|
434
|
+
});
|
|
435
|
+
/** Declared through `expectCompetingPayments`: nothing is judged until one is bound. */
|
|
436
|
+
let competing = false;
|
|
416
437
|
const budgetSpent = new AbortController();
|
|
417
438
|
const fixture = {
|
|
418
439
|
signal: budgetSpent.signal,
|
|
@@ -422,16 +443,7 @@ async function session(config, body, channel, backend, credentials) {
|
|
|
422
443
|
expectPayment: async (expected) => {
|
|
423
444
|
await fromBody({
|
|
424
445
|
type: "authorise",
|
|
425
|
-
authorisation:
|
|
426
|
-
instruction: wireInstruction(expected),
|
|
427
|
-
capacity_limit: minorUnits(config.capacityLimitMinor),
|
|
428
|
-
provider: {
|
|
429
|
-
account: config.system.providerTerms.account,
|
|
430
|
-
operation: config.system.providerTerms.operation,
|
|
431
|
-
idempotency_scope: config.system.providerTerms.idempotencyScope,
|
|
432
|
-
},
|
|
433
|
-
...(expected.outcome ? { expected_outcome: expected.outcome } : {}),
|
|
434
|
-
},
|
|
446
|
+
authorisation: authorisationOf(expected),
|
|
435
447
|
});
|
|
436
448
|
authorised = {
|
|
437
449
|
expected,
|
|
@@ -442,6 +454,55 @@ async function session(config, body, channel, backend, credentials) {
|
|
|
442
454
|
};
|
|
443
455
|
return authorised;
|
|
444
456
|
},
|
|
457
|
+
expectCompetingPayments: async (expected, shared) => {
|
|
458
|
+
if (expected.length < MIN_RACE_PARTIES ||
|
|
459
|
+
expected.length > MAX_RACE_PARTIES) {
|
|
460
|
+
throw new AuditUsageError(`expectCompetingPayments takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} payments`);
|
|
461
|
+
}
|
|
462
|
+
if (expected.some((entrant) => entrant.outcome !== undefined)) {
|
|
463
|
+
throw new AuditUsageError("expectCompetingPayments is judged on backend readings: declare no outcome");
|
|
464
|
+
}
|
|
465
|
+
await fromBody({
|
|
466
|
+
type: "authorise_competition",
|
|
467
|
+
authorisations: expected.map((entrant) => authorisationOf(entrant)),
|
|
468
|
+
shared_limit: minorUnits(shared.limitMinor),
|
|
469
|
+
});
|
|
470
|
+
competing = true;
|
|
471
|
+
const entrants = expected.map((entrant, competitor) => ({
|
|
472
|
+
expected: entrant,
|
|
473
|
+
bindProviderPayment: async (payment) => {
|
|
474
|
+
await fromBody({
|
|
475
|
+
type: "bind_provider_payment",
|
|
476
|
+
payment,
|
|
477
|
+
competitor,
|
|
478
|
+
});
|
|
479
|
+
// The first one bound is the payout the host judges and adapters read.
|
|
480
|
+
if (providerPayment === undefined) {
|
|
481
|
+
providerPayment = payment;
|
|
482
|
+
authorised = entrants[competitor];
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
}));
|
|
486
|
+
return entrants;
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
concurrency: {
|
|
490
|
+
race: async (parties, submit) => {
|
|
491
|
+
if (!Number.isSafeInteger(parties) ||
|
|
492
|
+
parties < MIN_RACE_PARTIES ||
|
|
493
|
+
parties > MAX_RACE_PARTIES) {
|
|
494
|
+
throw new AuditUsageError(`race takes ${String(MIN_RACE_PARTIES)}-${String(MAX_RACE_PARTIES)} parties`);
|
|
495
|
+
}
|
|
496
|
+
const opened = await fromBody({ type: "open_gate", parties });
|
|
497
|
+
const url = isRecord(opened) ? opened["url"] : undefined;
|
|
498
|
+
if (typeof url !== "string")
|
|
499
|
+
throw new Error("audit host opened a gate without an address");
|
|
500
|
+
const settled = await Promise.allSettled(Array.from({ length: parties }, (_, party) => submit(url, party)));
|
|
501
|
+
const report = await fromBody({ type: "close_gate" });
|
|
502
|
+
if (!isRaceReport(report))
|
|
503
|
+
throw new Error("audit host closed a gate without a report");
|
|
504
|
+
return { settled, report };
|
|
505
|
+
},
|
|
445
506
|
},
|
|
446
507
|
faults: {
|
|
447
508
|
loseResponseAfterCommit: async ({ payment, mode }) => {
|
package/dist/providers/fiat.d.ts
CHANGED
|
@@ -8,8 +8,23 @@ import type { CheckoutTerms, LedgerTerms, MollieTerms, ProviderWire, SourcedTerm
|
|
|
8
8
|
export declare const paystack: (terms: LedgerTerms) => ProviderWire;
|
|
9
9
|
/** `POST /v1/payouts`. A request without `destination` is refused here. */
|
|
10
10
|
export declare const stripePayouts: (terms: LedgerTerms) => ProviderWire;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* `POST /v1/transfers`, to a connected account. Checked against Stripe test
|
|
13
|
+
* mode on API version 2026-10-28.endive: a first transfer, a replay under the
|
|
14
|
+
* same key, the same key with another amount (`idempotency_error`), a refusal
|
|
15
|
+
* for funds (`balance_insufficient`) and its replay agree. Stripe also marks a
|
|
16
|
+
* replay with `Idempotent-Replayed`, which this format does not send.
|
|
17
|
+
*/
|
|
12
18
|
export declare const stripeTransfers: (terms: LedgerTerms) => ProviderWire;
|
|
19
|
+
/**
|
|
20
|
+
* Stripe Connect payouts: `POST /v1/accounts` and `POST /v1/accounts/:accountId/external_accounts`
|
|
21
|
+
* set up the recipient, `POST /v1/transfers` moves the money to it (the
|
|
22
|
+
* payment, as `stripeTransfers`), and `POST /v1/payouts` with a
|
|
23
|
+
* `Stripe-Account` header pays out of the connected account's balance. The
|
|
24
|
+
* key is optional, as at Stripe: a transfer sent without `Idempotency-Key`
|
|
25
|
+
* is a new transfer each time, so a retry after a lost reply pays again.
|
|
26
|
+
*/
|
|
27
|
+
export declare const stripeConnect: (terms: LedgerTerms) => ProviderWire;
|
|
13
28
|
/**
|
|
14
29
|
* `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
|
|
15
30
|
* `X-Idempotency-Key` and v3 reference reuse are unverified. The amount is a
|
package/dist/providers/fiat.js
CHANGED
|
@@ -88,6 +88,9 @@ const stripe = (path, terms, paid) => ({
|
|
|
88
88
|
destination: "destination",
|
|
89
89
|
},
|
|
90
90
|
fixed: terms,
|
|
91
|
+
// Stripe saves a refusal under its key and replays it, as observed in test
|
|
92
|
+
// mode on API version 2026-10-28.endive.
|
|
93
|
+
refusals: "final",
|
|
91
94
|
replies: {
|
|
92
95
|
paid: {
|
|
93
96
|
status: 200,
|
|
@@ -99,7 +102,7 @@ const stripe = (path, terms, paid) => ({
|
|
|
99
102
|
...paid,
|
|
100
103
|
},
|
|
101
104
|
},
|
|
102
|
-
//
|
|
105
|
+
// Both statuses were observed for transfers in test mode.
|
|
103
106
|
declined: {
|
|
104
107
|
status: 400,
|
|
105
108
|
body: {
|
|
@@ -133,7 +136,13 @@ export const stripePayouts = (terms) => stripe("/v1/payouts", terms, {
|
|
|
133
136
|
status: "pending",
|
|
134
137
|
type: "bank_account",
|
|
135
138
|
});
|
|
136
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* `POST /v1/transfers`, to a connected account. Checked against Stripe test
|
|
141
|
+
* mode on API version 2026-10-28.endive: a first transfer, a replay under the
|
|
142
|
+
* same key, the same key with another amount (`idempotency_error`), a refusal
|
|
143
|
+
* for funds (`balance_insufficient`) and its replay agree. Stripe also marks a
|
|
144
|
+
* replay with `Idempotent-Replayed`, which this format does not send.
|
|
145
|
+
*/
|
|
137
146
|
export const stripeTransfers = (terms) => stripe("/v1/transfers", terms, {
|
|
138
147
|
id: "tr_{{effectId}}",
|
|
139
148
|
object: "transfer",
|
|
@@ -143,6 +152,23 @@ export const stripeTransfers = (terms) => stripe("/v1/transfers", terms, {
|
|
|
143
152
|
reversals: { object: "list", data: [], has_more: false },
|
|
144
153
|
reversed: false,
|
|
145
154
|
});
|
|
155
|
+
/**
|
|
156
|
+
* Stripe Connect payouts: `POST /v1/accounts` and `POST /v1/accounts/:accountId/external_accounts`
|
|
157
|
+
* set up the recipient, `POST /v1/transfers` moves the money to it (the
|
|
158
|
+
* payment, as `stripeTransfers`), and `POST /v1/payouts` with a
|
|
159
|
+
* `Stripe-Account` header pays out of the connected account's balance. The
|
|
160
|
+
* key is optional, as at Stripe: a transfer sent without `Idempotency-Key`
|
|
161
|
+
* is a new transfer each time, so a retry after a lost reply pays again.
|
|
162
|
+
*/
|
|
163
|
+
export const stripeConnect = (terms) => ({
|
|
164
|
+
...stripeTransfers(terms),
|
|
165
|
+
key: { header: "idempotency-key", maxLength: 255, optional: true },
|
|
166
|
+
connect: {
|
|
167
|
+
accounts: "/v1/accounts",
|
|
168
|
+
externalAccounts: "/v1/accounts/:accountId/external_accounts",
|
|
169
|
+
payouts: "/v1/payouts",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
146
172
|
/**
|
|
147
173
|
* `POST /v3/transfers`. Keyed on the unique `reference`, as v3 coverage of
|
|
148
174
|
* `X-Idempotency-Key` and v3 reference reuse are unverified. The amount is a
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* that provider behaves.
|
|
6
6
|
*/
|
|
7
7
|
export { bridge, circleMint, fireblocks } from "./crypto.js";
|
|
8
|
-
export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
|
|
8
|
+
export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripeConnect, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
|
|
9
9
|
export type * from "./types.js";
|
package/dist/providers/index.js
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* that provider behaves.
|
|
6
6
|
*/
|
|
7
7
|
export { bridge, circleMint, fireblocks } from "./crypto.js";
|
|
8
|
-
export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
|
|
8
|
+
export { adyen, airwallex, checkout, dwolla, flutterwave, modernTreasury, molliePayouts, paypal, paystack, peach, razorpayx, revolut, stitch, stripeConnect, stripePayouts, stripeTransfers, wise, } from "./fiat.js";
|
|
@@ -57,6 +57,7 @@ export type WireKeyShape = "uuid_v4" | "lowercase_slug";
|
|
|
57
57
|
export type WireKey = Readonly<{
|
|
58
58
|
header: string;
|
|
59
59
|
maxLength?: number;
|
|
60
|
+
optional?: boolean;
|
|
60
61
|
} | {
|
|
61
62
|
field: string;
|
|
62
63
|
maxLength?: number;
|
|
@@ -106,6 +107,14 @@ export type ProviderWire = Readonly<{
|
|
|
106
107
|
conflict: Reply;
|
|
107
108
|
duplicate?: Reply;
|
|
108
109
|
}>;
|
|
110
|
+
/** Stripe Connect's setup and payout calls around a transfer. */
|
|
111
|
+
connect?: ConnectRoutes;
|
|
112
|
+
}>;
|
|
113
|
+
/** Where Stripe Connect's account setup and payout calls sit. */
|
|
114
|
+
export type ConnectRoutes = Readonly<{
|
|
115
|
+
accounts: string;
|
|
116
|
+
externalAccounts: string;
|
|
117
|
+
payouts: string;
|
|
109
118
|
}>;
|
|
110
119
|
/** A format of several calls, which the stand-in holds as its own flow. */
|
|
111
120
|
export type StagedWire = Readonly<{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandateops/audit",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Write audit tests that check a payout backend keeps one payment identity, money limits and commitments when a provider commits but the reply is lost. Needs the mandateops CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://getmandateops.com",
|