stable-ci 0.1.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/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/adapters/configured-http.js +173 -0
- package/dist/adapters/demo.js +150 -0
- package/dist/adapters/http-demo.js +232 -0
- package/dist/config.js +67 -0
- package/dist/core/demo-payment-app.js +254 -0
- package/dist/core/runner.js +16 -0
- package/dist/index.js +183 -0
- package/dist/invariants/builtins.js +138 -0
- package/dist/providers/bvnk.js +60 -0
- package/dist/providers/generic.js +13 -0
- package/dist/providers/types.js +1 -0
- package/dist/reporters/junit.js +65 -0
- package/dist/scenarios/builtins.js +106 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { startDemoPaymentApp, } from '../core/demo-payment-app.js';
|
|
2
|
+
import { createBvnkProvider } from '../providers/bvnk.js';
|
|
3
|
+
async function post(baseUrl, path, body = {}) {
|
|
4
|
+
const response = await fetch(baseUrl + path, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: {
|
|
7
|
+
'content-type': 'application/json',
|
|
8
|
+
},
|
|
9
|
+
body: JSON.stringify(body),
|
|
10
|
+
});
|
|
11
|
+
if (!response.ok && response.status !== 409) {
|
|
12
|
+
throw new Error('HTTP ' + response.status + ' from ' + path);
|
|
13
|
+
}
|
|
14
|
+
return response;
|
|
15
|
+
}
|
|
16
|
+
async function getState(baseUrl) {
|
|
17
|
+
const response = await fetch(baseUrl + '/state');
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error('Failed to read demo application state.');
|
|
20
|
+
}
|
|
21
|
+
return await response.json();
|
|
22
|
+
}
|
|
23
|
+
export function createHttpDemoAdapter(profile) {
|
|
24
|
+
return {
|
|
25
|
+
name: 'http-demo:' + profile,
|
|
26
|
+
async runScenario(scenario) {
|
|
27
|
+
const app = await startDemoPaymentApp(profile);
|
|
28
|
+
const paymentId = 'pay_http_001';
|
|
29
|
+
const amount = 100;
|
|
30
|
+
let providerStatus = 'completed';
|
|
31
|
+
let chainStatus = 'confirmed';
|
|
32
|
+
let webhookAccepted;
|
|
33
|
+
const webhook = async (eventId, status) => {
|
|
34
|
+
await post(app.baseUrl, '/webhook', {
|
|
35
|
+
eventId,
|
|
36
|
+
paymentId,
|
|
37
|
+
status,
|
|
38
|
+
amount,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await post(app.baseUrl, '/reset');
|
|
43
|
+
switch (scenario) {
|
|
44
|
+
case 'duplicate_webhook':
|
|
45
|
+
await webhook('evt_complete_1', 'completed');
|
|
46
|
+
await webhook('evt_complete_1', 'completed');
|
|
47
|
+
break;
|
|
48
|
+
case 'out_of_order_webhook':
|
|
49
|
+
await webhook('evt_complete_1', 'completed');
|
|
50
|
+
await webhook('evt_pending_2', 'pending');
|
|
51
|
+
break;
|
|
52
|
+
case 'missing_webhook':
|
|
53
|
+
await post(app.baseUrl, '/reconcile', {
|
|
54
|
+
paymentId,
|
|
55
|
+
chainStatus: 'confirmed',
|
|
56
|
+
amount,
|
|
57
|
+
});
|
|
58
|
+
break;
|
|
59
|
+
case 'provider_timeout_after_broadcast':
|
|
60
|
+
providerStatus = 'timeout';
|
|
61
|
+
await post(app.baseUrl, '/mark-unknown');
|
|
62
|
+
await post(app.baseUrl, '/retry', {
|
|
63
|
+
paymentId,
|
|
64
|
+
amount,
|
|
65
|
+
});
|
|
66
|
+
await post(app.baseUrl, '/reconcile', {
|
|
67
|
+
paymentId,
|
|
68
|
+
chainStatus: 'confirmed',
|
|
69
|
+
amount,
|
|
70
|
+
});
|
|
71
|
+
break;
|
|
72
|
+
case 'late_chain_confirmation':
|
|
73
|
+
providerStatus = 'timeout';
|
|
74
|
+
await post(app.baseUrl, '/mark-unknown');
|
|
75
|
+
await post(app.baseUrl, '/retry', {
|
|
76
|
+
paymentId,
|
|
77
|
+
amount,
|
|
78
|
+
});
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
80
|
+
await post(app.baseUrl, '/reconcile', {
|
|
81
|
+
paymentId,
|
|
82
|
+
chainStatus: 'confirmed',
|
|
83
|
+
amount,
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
case 'retry_after_unknown_settlement':
|
|
87
|
+
providerStatus = 'timeout';
|
|
88
|
+
await post(app.baseUrl, '/mark-unknown');
|
|
89
|
+
await post(app.baseUrl, '/retry', {
|
|
90
|
+
paymentId,
|
|
91
|
+
amount,
|
|
92
|
+
});
|
|
93
|
+
await post(app.baseUrl, '/reconcile', {
|
|
94
|
+
paymentId,
|
|
95
|
+
chainStatus: 'confirmed',
|
|
96
|
+
amount,
|
|
97
|
+
});
|
|
98
|
+
break;
|
|
99
|
+
case 'underpayment': {
|
|
100
|
+
providerStatus = 'underpaid';
|
|
101
|
+
const provider = createBvnkProvider('stable-ci-local-secret');
|
|
102
|
+
const rendered = provider.render({
|
|
103
|
+
eventId: 'evt_underpayment_1',
|
|
104
|
+
paymentId,
|
|
105
|
+
eventType: 'transactionConfirmed',
|
|
106
|
+
status: 'underpaid',
|
|
107
|
+
amount,
|
|
108
|
+
actualAmount: 60,
|
|
109
|
+
asset: 'USDC',
|
|
110
|
+
});
|
|
111
|
+
const response = await fetch(app.baseUrl + '/webhook', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: rendered.headers,
|
|
114
|
+
body: rendered.body,
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error('Underpayment webhook returned HTTP ' +
|
|
118
|
+
response.status);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'overpayment': {
|
|
123
|
+
const provider = createBvnkProvider('stable-ci-local-secret');
|
|
124
|
+
const rendered = provider.render({
|
|
125
|
+
eventId: 'evt_overpayment_1',
|
|
126
|
+
paymentId,
|
|
127
|
+
eventType: 'transactionConfirmed',
|
|
128
|
+
status: 'completed',
|
|
129
|
+
amount,
|
|
130
|
+
actualAmount: 140,
|
|
131
|
+
asset: 'USDC',
|
|
132
|
+
});
|
|
133
|
+
const response = await fetch(app.baseUrl + '/webhook', {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: rendered.headers,
|
|
136
|
+
body: rendered.body,
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error('Overpayment webhook returned HTTP ' +
|
|
140
|
+
response.status);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case 'late_payment': {
|
|
145
|
+
providerStatus = 'expired';
|
|
146
|
+
const provider = createBvnkProvider('stable-ci-local-secret');
|
|
147
|
+
const rendered = provider.render({
|
|
148
|
+
eventId: 'evt_late_payment_1',
|
|
149
|
+
paymentId,
|
|
150
|
+
eventType: 'transactionLate',
|
|
151
|
+
status: 'expired',
|
|
152
|
+
amount,
|
|
153
|
+
actualAmount: amount,
|
|
154
|
+
asset: 'USDC',
|
|
155
|
+
});
|
|
156
|
+
const response = await fetch(app.baseUrl + '/webhook', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: rendered.headers,
|
|
159
|
+
body: rendered.body,
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
throw new Error('Late payment webhook returned HTTP ' +
|
|
163
|
+
response.status);
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'invalid_signature': {
|
|
168
|
+
providerStatus = 'failed';
|
|
169
|
+
chainStatus = 'not_broadcast';
|
|
170
|
+
const payload = JSON.stringify({
|
|
171
|
+
source: 'payment',
|
|
172
|
+
event: 'statusChanged',
|
|
173
|
+
data: {
|
|
174
|
+
uuid: paymentId,
|
|
175
|
+
reference: 'evt_invalid_signature',
|
|
176
|
+
type: 'IN',
|
|
177
|
+
subType: 'merchantPayIn',
|
|
178
|
+
status: 'COMPLETE',
|
|
179
|
+
displayCurrency: {
|
|
180
|
+
currency: 'USDC',
|
|
181
|
+
amount,
|
|
182
|
+
actual: amount,
|
|
183
|
+
},
|
|
184
|
+
paidCurrency: {
|
|
185
|
+
currency: 'USDC',
|
|
186
|
+
amount,
|
|
187
|
+
actual: amount,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const response = await fetch(app.baseUrl + '/webhook', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: {
|
|
194
|
+
'content-type': 'application/json',
|
|
195
|
+
'x-signature': 'invalid-signature',
|
|
196
|
+
},
|
|
197
|
+
body: payload,
|
|
198
|
+
});
|
|
199
|
+
webhookAccepted = response.ok;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const state = await getState(app.baseUrl);
|
|
204
|
+
return {
|
|
205
|
+
scenario,
|
|
206
|
+
paymentId,
|
|
207
|
+
expectedAmount: amount,
|
|
208
|
+
receivedAmount: scenario === 'underpayment'
|
|
209
|
+
? 60
|
|
210
|
+
: scenario === 'overpayment'
|
|
211
|
+
? 140
|
|
212
|
+
: scenario === 'late_payment'
|
|
213
|
+
? 100
|
|
214
|
+
: undefined,
|
|
215
|
+
providerStatus,
|
|
216
|
+
chainStatus,
|
|
217
|
+
webhookDeliveries: state.webhookDeliveries,
|
|
218
|
+
ledgerEntries: state.ledgerEntries,
|
|
219
|
+
creditedAmount: state.creditedAmount,
|
|
220
|
+
applicationStatus: state.applicationStatus,
|
|
221
|
+
retryAttempts: state.retryAttempts,
|
|
222
|
+
settlementWasUnknown: state.settlementWasUnknown,
|
|
223
|
+
recovered: state.recovered,
|
|
224
|
+
webhookAccepted,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await app.close();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const scenarioSchema = z.enum([
|
|
5
|
+
'duplicate_webhook',
|
|
6
|
+
'out_of_order_webhook',
|
|
7
|
+
'missing_webhook',
|
|
8
|
+
'invalid_signature',
|
|
9
|
+
'underpayment',
|
|
10
|
+
'overpayment',
|
|
11
|
+
'late_payment',
|
|
12
|
+
]);
|
|
13
|
+
const applicationStatusSchema = z.enum([
|
|
14
|
+
'none',
|
|
15
|
+
'pending',
|
|
16
|
+
'completed',
|
|
17
|
+
'failed',
|
|
18
|
+
'manual_review',
|
|
19
|
+
]);
|
|
20
|
+
const expectedOutcomeSchema = z.object({
|
|
21
|
+
applicationStatus: z.union([
|
|
22
|
+
applicationStatusSchema,
|
|
23
|
+
z.array(applicationStatusSchema),
|
|
24
|
+
]).optional(),
|
|
25
|
+
ledgerEntries: z.number().int().nonnegative().optional(),
|
|
26
|
+
credit: z.enum([
|
|
27
|
+
'exact_expected',
|
|
28
|
+
'none',
|
|
29
|
+
'received_amount',
|
|
30
|
+
'any',
|
|
31
|
+
]).optional(),
|
|
32
|
+
retryAttempts: z.number().int().nonnegative().optional(),
|
|
33
|
+
webhookAccepted: z.boolean().optional(),
|
|
34
|
+
});
|
|
35
|
+
const configSchema = z.object({
|
|
36
|
+
provider: z.enum(['generic', 'bvnk']).default('generic'),
|
|
37
|
+
webhookSecret: z.string().min(1).optional(),
|
|
38
|
+
target: z.object({
|
|
39
|
+
name: z.string().min(1),
|
|
40
|
+
baseUrl: z.string().url(),
|
|
41
|
+
endpoints: z.object({
|
|
42
|
+
reset: z.string().min(1),
|
|
43
|
+
webhook: z.string().min(1),
|
|
44
|
+
state: z.string().min(1),
|
|
45
|
+
reconcile: z.string().min(1).optional(),
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
payment: z.object({
|
|
49
|
+
id: z.string().min(1),
|
|
50
|
+
amount: z.number().positive(),
|
|
51
|
+
asset: z.string().min(1),
|
|
52
|
+
}),
|
|
53
|
+
scenarios: z.array(scenarioSchema).min(1),
|
|
54
|
+
expectations: z.record(z.string(), expectedOutcomeSchema).optional(),
|
|
55
|
+
});
|
|
56
|
+
export function loadConfig(path) {
|
|
57
|
+
if (!fs.existsSync(path)) {
|
|
58
|
+
throw new Error('Config file not found: ' + path);
|
|
59
|
+
}
|
|
60
|
+
const raw = fs.readFileSync(path, 'utf8');
|
|
61
|
+
const parsed = configSchema.parse(parse(raw));
|
|
62
|
+
return {
|
|
63
|
+
...parsed,
|
|
64
|
+
scenarios: parsed.scenarios,
|
|
65
|
+
expectations: parsed.expectations,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
function initialState() {
|
|
4
|
+
return {
|
|
5
|
+
paymentId: '',
|
|
6
|
+
applicationStatus: 'none',
|
|
7
|
+
webhookDeliveries: 0,
|
|
8
|
+
ledgerEntries: 0,
|
|
9
|
+
creditedAmount: 0,
|
|
10
|
+
retryAttempts: 0,
|
|
11
|
+
settlementWasUnknown: false,
|
|
12
|
+
recovered: false,
|
|
13
|
+
seenEventIds: [],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function readJson(req) {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
for await (const chunk of req) {
|
|
19
|
+
chunks.push(Buffer.from(chunk));
|
|
20
|
+
}
|
|
21
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
22
|
+
if (!raw) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
async function readRawJson(req) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of req) {
|
|
30
|
+
chunks.push(Buffer.from(chunk));
|
|
31
|
+
}
|
|
32
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
33
|
+
return {
|
|
34
|
+
raw,
|
|
35
|
+
body: raw
|
|
36
|
+
? JSON.parse(raw)
|
|
37
|
+
: {},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function sendJson(res, status, body) {
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
'content-type': 'application/json',
|
|
43
|
+
});
|
|
44
|
+
res.end(JSON.stringify(body));
|
|
45
|
+
}
|
|
46
|
+
export async function startDemoPaymentApp(profile, port = 0) {
|
|
47
|
+
let state = initialState();
|
|
48
|
+
const server = createServer(async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
51
|
+
if (req.method === 'POST' && url.pathname === '/reset') {
|
|
52
|
+
state = initialState();
|
|
53
|
+
sendJson(res, 200, state);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (req.method === 'POST' && url.pathname === '/mark-unknown') {
|
|
57
|
+
state.settlementWasUnknown = true;
|
|
58
|
+
sendJson(res, 200, state);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (req.method === 'POST' && url.pathname === '/webhook') {
|
|
62
|
+
const { body, raw } = await readRawJson(req);
|
|
63
|
+
const data = typeof body.data === 'object' &&
|
|
64
|
+
body.data !== null
|
|
65
|
+
? body.data
|
|
66
|
+
: undefined;
|
|
67
|
+
const displayCurrency = data &&
|
|
68
|
+
typeof data.displayCurrency === 'object' &&
|
|
69
|
+
data.displayCurrency !== null
|
|
70
|
+
? data.displayCurrency
|
|
71
|
+
: undefined;
|
|
72
|
+
const paidCurrency = data &&
|
|
73
|
+
typeof data.paidCurrency === 'object' &&
|
|
74
|
+
data.paidCurrency !== null
|
|
75
|
+
? data.paidCurrency
|
|
76
|
+
: undefined;
|
|
77
|
+
const walletCurrency = data &&
|
|
78
|
+
typeof data.walletCurrency === 'object' &&
|
|
79
|
+
data.walletCurrency !== null
|
|
80
|
+
? data.walletCurrency
|
|
81
|
+
: undefined;
|
|
82
|
+
const isBvnk = body.source === 'payment' &&
|
|
83
|
+
(body.event === 'statusChanged' ||
|
|
84
|
+
body.event === 'transactionConfirmed' ||
|
|
85
|
+
body.event === 'transactionLate') &&
|
|
86
|
+
data !== undefined;
|
|
87
|
+
if (profile === 'safe' && isBvnk) {
|
|
88
|
+
const suppliedSignature = String(req.headers['x-signature'] ?? '');
|
|
89
|
+
const expectedSignature = createHmac('sha256', 'stable-ci-local-secret')
|
|
90
|
+
.update(raw, 'utf8')
|
|
91
|
+
.digest('base64');
|
|
92
|
+
if (suppliedSignature !== expectedSignature) {
|
|
93
|
+
sendJson(res, 401, {
|
|
94
|
+
error: 'Invalid BVNK webhook signature',
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const rawStatus = isBvnk
|
|
100
|
+
? String(data.status ?? '')
|
|
101
|
+
: String(body.status ?? 'pending');
|
|
102
|
+
const status = isBvnk &&
|
|
103
|
+
body.event === 'transactionLate'
|
|
104
|
+
? 'manual_review'
|
|
105
|
+
: rawStatus === 'COMPLETE'
|
|
106
|
+
? 'completed'
|
|
107
|
+
: rawStatus === 'PROCESSING'
|
|
108
|
+
? 'pending'
|
|
109
|
+
: rawStatus === 'EXPIRED'
|
|
110
|
+
? 'failed'
|
|
111
|
+
: rawStatus === 'UNDERPAID'
|
|
112
|
+
? 'manual_review'
|
|
113
|
+
: rawStatus;
|
|
114
|
+
const paymentId = isBvnk
|
|
115
|
+
? String(data.uuid ?? '')
|
|
116
|
+
: String(body.paymentId ?? '');
|
|
117
|
+
const eventId = isBvnk
|
|
118
|
+
? String(data.reference ?? '') +
|
|
119
|
+
':' +
|
|
120
|
+
String(body.event ?? '') +
|
|
121
|
+
':' +
|
|
122
|
+
rawStatus
|
|
123
|
+
: String(body.eventId ?? '');
|
|
124
|
+
const amount = isBvnk
|
|
125
|
+
? Number(paidCurrency?.amount ??
|
|
126
|
+
displayCurrency?.amount ??
|
|
127
|
+
0)
|
|
128
|
+
: Number(body.amount ?? 0);
|
|
129
|
+
const actualAmount = isBvnk
|
|
130
|
+
? Number(body.event === 'transactionLate'
|
|
131
|
+
? walletCurrency?.actual ??
|
|
132
|
+
paidCurrency?.actual ??
|
|
133
|
+
displayCurrency?.actual ??
|
|
134
|
+
amount
|
|
135
|
+
: paidCurrency?.actual ??
|
|
136
|
+
displayCurrency?.actual ??
|
|
137
|
+
amount)
|
|
138
|
+
: amount;
|
|
139
|
+
state.paymentId = paymentId;
|
|
140
|
+
state.webhookDeliveries += 1;
|
|
141
|
+
const duplicate = state.seenEventIds.includes(eventId);
|
|
142
|
+
if (profile === 'safe' && duplicate) {
|
|
143
|
+
sendJson(res, 200, {
|
|
144
|
+
duplicate: true,
|
|
145
|
+
state,
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
state.seenEventIds.push(eventId);
|
|
150
|
+
if (profile === 'safe') {
|
|
151
|
+
if (status === 'pending' &&
|
|
152
|
+
state.applicationStatus === 'completed') {
|
|
153
|
+
sendJson(res, 200, state);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
state.applicationStatus = status;
|
|
157
|
+
if (status === 'completed' &&
|
|
158
|
+
state.ledgerEntries === 0) {
|
|
159
|
+
state.ledgerEntries = 1;
|
|
160
|
+
state.creditedAmount = amount;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
if (isBvnk &&
|
|
165
|
+
body.event === 'transactionLate') {
|
|
166
|
+
state.applicationStatus = 'completed';
|
|
167
|
+
state.ledgerEntries += 1;
|
|
168
|
+
state.creditedAmount += actualAmount;
|
|
169
|
+
}
|
|
170
|
+
else if (rawStatus === 'UNDERPAID') {
|
|
171
|
+
state.applicationStatus = 'completed';
|
|
172
|
+
state.ledgerEntries += 1;
|
|
173
|
+
state.creditedAmount += amount;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
state.applicationStatus = status;
|
|
177
|
+
if (status === 'completed') {
|
|
178
|
+
const creditAmount = isBvnk &&
|
|
179
|
+
body.event === 'transactionConfirmed' ||
|
|
180
|
+
body.event === 'transactionLate' &&
|
|
181
|
+
actualAmount > amount
|
|
182
|
+
? actualAmount
|
|
183
|
+
: amount;
|
|
184
|
+
state.ledgerEntries += 1;
|
|
185
|
+
state.creditedAmount += creditAmount;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sendJson(res, 200, state);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (req.method === 'POST' && url.pathname === '/retry') {
|
|
193
|
+
const { body, raw } = await readRawJson(req);
|
|
194
|
+
const amount = Number(body.amount ?? 0);
|
|
195
|
+
if (profile === 'safe' &&
|
|
196
|
+
state.settlementWasUnknown) {
|
|
197
|
+
sendJson(res, 409, {
|
|
198
|
+
error: 'Settlement outcome is unknown. Reconcile before retrying.',
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
state.retryAttempts += 1;
|
|
203
|
+
if (profile === 'unsafe') {
|
|
204
|
+
state.applicationStatus = 'completed';
|
|
205
|
+
state.ledgerEntries += 1;
|
|
206
|
+
state.creditedAmount += amount;
|
|
207
|
+
}
|
|
208
|
+
sendJson(res, 200, state);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (req.method === 'POST' && url.pathname === '/reconcile') {
|
|
212
|
+
const { body, raw } = await readRawJson(req);
|
|
213
|
+
const chainStatus = String(body.chainStatus ?? '');
|
|
214
|
+
const amount = Number(body.amount ?? 0);
|
|
215
|
+
if (profile === 'safe' &&
|
|
216
|
+
chainStatus === 'confirmed') {
|
|
217
|
+
if (state.ledgerEntries === 0) {
|
|
218
|
+
state.ledgerEntries = 1;
|
|
219
|
+
state.creditedAmount = amount;
|
|
220
|
+
}
|
|
221
|
+
state.applicationStatus = 'completed';
|
|
222
|
+
state.recovered = true;
|
|
223
|
+
}
|
|
224
|
+
sendJson(res, 200, state);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (req.method === 'GET' && url.pathname === '/state') {
|
|
228
|
+
sendJson(res, 200, state);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
sendJson(res, 500, {
|
|
235
|
+
error: error instanceof Error
|
|
236
|
+
? error.message
|
|
237
|
+
: 'Unknown error',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
await new Promise((resolve, reject) => {
|
|
242
|
+
server.once('error', reject);
|
|
243
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
244
|
+
});
|
|
245
|
+
const address = server.address();
|
|
246
|
+
return {
|
|
247
|
+
baseUrl: 'http://127.0.0.1:' + address.port,
|
|
248
|
+
async close() {
|
|
249
|
+
await new Promise((resolve) => {
|
|
250
|
+
server.close(() => resolve());
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { evaluateInvariants } from '../invariants/builtins.js';
|
|
2
|
+
import { scenarios } from '../scenarios/builtins.js';
|
|
3
|
+
export async function runSuite(adapter, selectedScenarios = scenarios) {
|
|
4
|
+
const results = [];
|
|
5
|
+
for (const scenario of selectedScenarios) {
|
|
6
|
+
const observation = await adapter.runScenario(scenario.name);
|
|
7
|
+
const invariantResults = evaluateInvariants(observation, scenario.expected);
|
|
8
|
+
results.push({
|
|
9
|
+
scenario,
|
|
10
|
+
observation,
|
|
11
|
+
invariants: invariantResults,
|
|
12
|
+
passed: invariantResults.every((result) => result.passed),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return results;
|
|
16
|
+
}
|