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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 saldfsdk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,346 @@
1
+ # stable-ci
2
+
3
+ **Break your stablecoin integration before production.**
4
+
5
+ `stable-ci` injects deterministic payment failures into stablecoin payment integrations and verifies that your application and ledger still converge to the expected financial state.
6
+
7
+ It is designed for CI: define your payment policy, run failure scenarios against your integration, and fail the build when money moves incorrectly.
8
+
9
+ ## What it catches
10
+
11
+ `stable-ci` currently tests:
12
+
13
+ - Duplicate webhooks
14
+ - Out-of-order webhooks
15
+ - Missing webhooks
16
+ - Provider timeout after broadcast
17
+ - Late blockchain confirmation
18
+ - Retry while settlement is unknown
19
+ - Invalid webhook signatures
20
+ - Underpayments
21
+ - Overpayments
22
+ - Late payments
23
+
24
+ It verifies outcomes such as:
25
+
26
+ - exactly-once ledger posting
27
+ - credited amount
28
+ - final application state
29
+ - retry behavior
30
+ - webhook acceptance
31
+ - recovery after missing events
32
+
33
+ ## Example
34
+
35
+ A broken overpayment handler:
36
+
37
+ ```text
38
+ FAIL overpayment
39
+ ledger_delta_equals_settlement_amount
40
+ Expected USD 100.00, ledger credited USD 140.00.
41
+ ```
42
+
43
+ Instead of only asking whether an API request succeeded, `stable-ci` checks whether the resulting financial state is correct.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ npm install --save-dev stable-ci
49
+ ```
50
+
51
+ Or run it directly:
52
+
53
+ ```bash
54
+ npx stable-ci --help
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ Create `stable-ci.yml`:
60
+
61
+ ```yaml
62
+ provider: bvnk
63
+
64
+ webhookSecret: your-test-webhook-secret
65
+
66
+ target:
67
+ name: payment-app
68
+ baseUrl: http://127.0.0.1:4310
69
+ endpoints:
70
+ reset: /reset
71
+ webhook: /webhook
72
+ state: /state
73
+ reconcile: /reconcile
74
+
75
+ payment:
76
+ id: pay_test_001
77
+ amount: 100
78
+ asset: USDC
79
+
80
+ scenarios:
81
+ - duplicate_webhook
82
+ - out_of_order_webhook
83
+ - missing_webhook
84
+ - invalid_signature
85
+ - underpayment
86
+ - overpayment
87
+ - late_payment
88
+
89
+ expectations:
90
+ underpayment:
91
+ applicationStatus: manual_review
92
+ ledgerEntries: 0
93
+ credit: none
94
+
95
+ overpayment:
96
+ applicationStatus: completed
97
+ ledgerEntries: 1
98
+ credit: exact_expected
99
+
100
+ late_payment:
101
+ applicationStatus: manual_review
102
+ ledgerEntries: 0
103
+ credit: none
104
+ ```
105
+
106
+ Run:
107
+
108
+ ```bash
109
+ npx stable-ci run --config stable-ci.yml
110
+ ```
111
+
112
+ Example output:
113
+
114
+ ```text
115
+ Stablecoin Reliability CI
116
+ =========================
117
+ Adapter: payment-app [bvnk]
118
+
119
+ PASS duplicate_webhook
120
+ PASS out_of_order_webhook
121
+ PASS missing_webhook
122
+ PASS invalid_signature
123
+ PASS underpayment
124
+ FAIL overpayment
125
+ PASS late_payment
126
+
127
+ 6 passed, 1 failed
128
+ ```
129
+
130
+ A failed scenario causes a non-zero exit code, so it can fail CI.
131
+
132
+ ## Expected outcomes
133
+
134
+ Different applications may intentionally handle payment exceptions differently.
135
+
136
+ For example, an overpayment may credit only the requested amount:
137
+
138
+ ```yaml
139
+ expectations:
140
+ overpayment:
141
+ applicationStatus: completed
142
+ ledgerEntries: 1
143
+ credit: exact_expected
144
+ ```
145
+
146
+ While another application may intentionally credit the entire received amount:
147
+
148
+ ```yaml
149
+ expectations:
150
+ overpayment:
151
+ applicationStatus: completed
152
+ ledgerEntries: 1
153
+ credit: received_amount
154
+ ```
155
+
156
+ `stable-ci` tests against your declared financial policy instead of assuming that every integration has the same correct outcome.
157
+
158
+ ## GitHub Actions
159
+
160
+ This repository includes a composite GitHub Action.
161
+
162
+ ```yaml
163
+ steps:
164
+ - uses: actions/checkout@v4
165
+
166
+ - uses: saldfsdk/stable-ci@v0.1.0
167
+ with:
168
+ config: stable-ci.yml
169
+ junit: reports/stable-ci.xml
170
+ ```
171
+
172
+ The action runs the configured scenarios and writes a JUnit report.
173
+
174
+ ## JUnit
175
+
176
+ You can generate a JUnit XML report directly:
177
+
178
+ ```bash
179
+ npx stable-ci run \
180
+ --config stable-ci.yml \
181
+ --junit reports/stable-ci.xml
182
+ ```
183
+
184
+ JUnit reports are written even when reliability scenarios fail.
185
+
186
+ ## JSON output
187
+
188
+ For machine-readable output:
189
+
190
+ ```bash
191
+ npx stable-ci test --profile safe --json
192
+ ```
193
+
194
+ ## Built-in scenarios
195
+
196
+ List all scenarios:
197
+
198
+ ```bash
199
+ npx stable-ci scenarios
200
+ ```
201
+
202
+ Current scenarios include:
203
+
204
+ - `duplicate_webhook`
205
+ - `out_of_order_webhook`
206
+ - `missing_webhook`
207
+ - `provider_timeout_after_broadcast`
208
+ - `late_chain_confirmation`
209
+ - `retry_after_unknown_settlement`
210
+ - `invalid_signature`
211
+ - `underpayment`
212
+ - `overpayment`
213
+ - `late_payment`
214
+
215
+ ## Provider support
216
+
217
+ ### BVNK
218
+
219
+ The current BVNK adapter models payment webhook behavior including:
220
+
221
+ - signed webhook delivery
222
+ - `statusChanged`
223
+ - `transactionConfirmed`
224
+ - `transactionLate`
225
+ - underpayments
226
+ - overpayments
227
+ - late payments
228
+
229
+ Provider payload fixtures are based on publicly documented behavior and are intended for reliability testing.
230
+
231
+ `stable-ci` is not affiliated with or endorsed by BVNK.
232
+
233
+ ### Generic
234
+
235
+ A generic provider adapter is also included for provider-independent testing.
236
+
237
+ ## Architecture
238
+
239
+ ```text
240
+ stable-ci.yml
241
+ |
242
+ v
243
+ Scenario Runner
244
+ |
245
+ v
246
+ Fault Injection
247
+ |
248
+ v
249
+ Provider / Webhook / Application
250
+ |
251
+ v
252
+ Observed State
253
+ |
254
+ v
255
+ Expected Outcome + Invariant Engine
256
+ |
257
+ +---- PASS
258
+ |
259
+ +---- FAIL
260
+ |
261
+ +-- CLI
262
+ +-- JSON
263
+ +-- JUnit
264
+ ```
265
+
266
+ ## Why stable-ci?
267
+
268
+ Payment integrations can fail even when individual API calls appear successful.
269
+
270
+ Examples include:
271
+
272
+ - the same webhook being delivered twice
273
+ - lifecycle events arriving out of order
274
+ - a payment being confirmed after an application timeout
275
+ - retrying while settlement is still unknown
276
+ - an invalid webhook being accepted
277
+ - an underpayment being treated as fully paid
278
+ - an overpayment being credited incorrectly
279
+ - an expired payment being revived after late funds arrive
280
+
281
+ These are not only API correctness problems.
282
+
283
+ They are **money movement correctness** problems.
284
+
285
+ `stable-ci` is designed to test the final financial outcome, not only whether a request returned HTTP 200.
286
+
287
+ ## Current status
288
+
289
+ `stable-ci` is an early-stage developer tool.
290
+
291
+ The current version focuses on deterministic local and CI testing.
292
+
293
+ It does not yet provide full production blockchain observation or hosted monitoring.
294
+
295
+ Planned areas include:
296
+
297
+ - additional provider adapters
298
+ - richer CI reporting
299
+ - blockchain/RPC observers
300
+ - provider sandbox drift detection
301
+ - hosted reliability testing
302
+
303
+ ## Development
304
+
305
+ Install dependencies:
306
+
307
+ ```bash
308
+ npm install
309
+ ```
310
+
311
+ Run all tests and build:
312
+
313
+ ```bash
314
+ npm run check
315
+ ```
316
+
317
+ Run the local safe demo:
318
+
319
+ ```bash
320
+ npm run build
321
+ node dist/index.js test-http --profile safe
322
+ ```
323
+
324
+ Expected result:
325
+
326
+ ```text
327
+ 10 passed, 0 failed
328
+ ```
329
+
330
+ Run the intentionally unsafe demo:
331
+
332
+ ```bash
333
+ node dist/index.js test-http --profile unsafe
334
+ ```
335
+
336
+ Expected result:
337
+
338
+ ```text
339
+ 0 passed, 10 failed
340
+ ```
341
+
342
+ The unsafe profile intentionally contains broken payment-handling behavior so that the reliability checks can demonstrate what they detect.
343
+
344
+ ## License
345
+
346
+ MIT
@@ -0,0 +1,173 @@
1
+ import { z } from 'zod';
2
+ import { createBvnkProvider } from '../providers/bvnk.js';
3
+ import { createGenericProvider } from '../providers/generic.js';
4
+ const stateSchema = z.object({
5
+ applicationStatus: z.enum([
6
+ 'none',
7
+ 'pending',
8
+ 'completed',
9
+ 'failed',
10
+ 'manual_review',
11
+ ]),
12
+ webhookDeliveries: z.number(),
13
+ ledgerEntries: z.number(),
14
+ creditedAmount: z.number(),
15
+ retryAttempts: z.number().default(0),
16
+ settlementWasUnknown: z.boolean().default(false),
17
+ recovered: z.boolean().default(false),
18
+ });
19
+ function getProvider(config) {
20
+ if (config.provider === 'bvnk') {
21
+ return createBvnkProvider(config.webhookSecret ?? 'stable-ci-local-secret');
22
+ }
23
+ return createGenericProvider();
24
+ }
25
+ async function postJson(baseUrl, path, body = {}) {
26
+ const response = await fetch(baseUrl + path, {
27
+ method: 'POST',
28
+ headers: {
29
+ 'content-type': 'application/json',
30
+ },
31
+ body: JSON.stringify(body),
32
+ });
33
+ if (!response.ok) {
34
+ throw new Error('HTTP ' + response.status + ' from ' + path);
35
+ }
36
+ }
37
+ async function sendWebhook(config, provider, eventId, status, actualAmount, eventType) {
38
+ const rendered = provider.render({
39
+ eventId,
40
+ paymentId: config.payment.id,
41
+ status,
42
+ amount: config.payment.amount,
43
+ asset: config.payment.asset,
44
+ actualAmount,
45
+ eventType,
46
+ });
47
+ const response = await fetch(config.target.baseUrl +
48
+ config.target.endpoints.webhook, {
49
+ method: 'POST',
50
+ headers: rendered.headers,
51
+ body: rendered.body,
52
+ });
53
+ if (!response.ok) {
54
+ throw new Error('Webhook returned HTTP ' + response.status);
55
+ }
56
+ return response.status;
57
+ }
58
+ async function sendInvalidSignatureWebhook(config, provider) {
59
+ const rendered = provider.render({
60
+ eventId: 'evt_invalid_signature',
61
+ paymentId: config.payment.id,
62
+ status: 'completed',
63
+ amount: config.payment.amount,
64
+ asset: config.payment.asset,
65
+ });
66
+ const response = await fetch(config.target.baseUrl +
67
+ config.target.endpoints.webhook, {
68
+ method: 'POST',
69
+ headers: {
70
+ ...rendered.headers,
71
+ 'x-signature': 'invalid-signature',
72
+ },
73
+ body: rendered.body,
74
+ });
75
+ return response.ok;
76
+ }
77
+ async function getState(config) {
78
+ const response = await fetch(config.target.baseUrl +
79
+ config.target.endpoints.state);
80
+ if (!response.ok) {
81
+ throw new Error('Failed to read target application state.');
82
+ }
83
+ return stateSchema.parse(await response.json());
84
+ }
85
+ export function createConfiguredHttpAdapter(config) {
86
+ const provider = getProvider(config);
87
+ return {
88
+ name: config.target.name + ' [' + provider.name + ']',
89
+ async runScenario(scenario) {
90
+ const baseUrl = config.target.baseUrl;
91
+ const endpoints = config.target.endpoints;
92
+ const payment = config.payment;
93
+ await postJson(baseUrl, endpoints.reset);
94
+ let providerStatus = 'completed';
95
+ let chainStatus = 'confirmed';
96
+ let webhookAccepted;
97
+ let receivedAmount;
98
+ switch (scenario) {
99
+ case 'duplicate_webhook':
100
+ await sendWebhook(config, provider, 'evt_complete_1', 'completed');
101
+ await sendWebhook(config, provider, 'evt_complete_1', 'completed');
102
+ break;
103
+ case 'out_of_order_webhook':
104
+ await sendWebhook(config, provider, 'evt_complete_1', 'completed');
105
+ await sendWebhook(config, provider, 'evt_pending_2', 'pending');
106
+ break;
107
+ case 'missing_webhook':
108
+ if (endpoints.reconcile) {
109
+ await postJson(baseUrl, endpoints.reconcile, {
110
+ paymentId: payment.id,
111
+ chainStatus: 'confirmed',
112
+ amount: payment.amount,
113
+ asset: payment.asset,
114
+ });
115
+ }
116
+ break;
117
+ case 'invalid_signature':
118
+ if (provider.name !== 'bvnk') {
119
+ throw new Error('invalid_signature currently requires the BVNK provider.');
120
+ }
121
+ webhookAccepted =
122
+ await sendInvalidSignatureWebhook(config, provider);
123
+ providerStatus = 'failed';
124
+ chainStatus = 'not_broadcast';
125
+ break;
126
+ case 'underpayment':
127
+ if (provider.name !== 'bvnk') {
128
+ throw new Error('underpayment currently requires the BVNK provider.');
129
+ }
130
+ receivedAmount = payment.amount * 0.6;
131
+ providerStatus = 'underpaid';
132
+ await sendWebhook(config, provider, 'evt_underpayment_1', 'underpaid', receivedAmount, 'transactionConfirmed');
133
+ break;
134
+ case 'overpayment':
135
+ if (provider.name !== 'bvnk') {
136
+ throw new Error('overpayment currently requires the BVNK provider.');
137
+ }
138
+ receivedAmount = payment.amount * 1.4;
139
+ providerStatus = 'completed';
140
+ await sendWebhook(config, provider, 'evt_overpayment_1', 'completed', receivedAmount, 'transactionConfirmed');
141
+ break;
142
+ case 'late_payment':
143
+ if (provider.name !== 'bvnk') {
144
+ throw new Error('late_payment currently requires the BVNK provider.');
145
+ }
146
+ receivedAmount = payment.amount;
147
+ providerStatus = 'expired';
148
+ await sendWebhook(config, provider, 'evt_late_payment_1', 'expired', receivedAmount, 'transactionLate');
149
+ break;
150
+ default:
151
+ throw new Error('Scenario is not supported by the configured HTTP adapter yet: ' +
152
+ scenario);
153
+ }
154
+ const state = await getState(config);
155
+ return {
156
+ scenario,
157
+ paymentId: payment.id,
158
+ expectedAmount: payment.amount,
159
+ receivedAmount,
160
+ providerStatus,
161
+ chainStatus,
162
+ webhookDeliveries: state.webhookDeliveries,
163
+ ledgerEntries: state.ledgerEntries,
164
+ creditedAmount: state.creditedAmount,
165
+ applicationStatus: state.applicationStatus,
166
+ retryAttempts: state.retryAttempts,
167
+ settlementWasUnknown: state.settlementWasUnknown,
168
+ recovered: state.recovered,
169
+ webhookAccepted,
170
+ };
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,150 @@
1
+ const baseObservation = (scenario) => ({
2
+ scenario,
3
+ paymentId: 'pay_demo_001',
4
+ expectedAmount: 100,
5
+ providerStatus: 'completed',
6
+ chainStatus: 'confirmed',
7
+ webhookDeliveries: 1,
8
+ ledgerEntries: 1,
9
+ creditedAmount: 100,
10
+ applicationStatus: 'completed',
11
+ retryAttempts: 0,
12
+ settlementWasUnknown: false,
13
+ recovered: false,
14
+ });
15
+ function safeObservation(scenario) {
16
+ const o = baseObservation(scenario);
17
+ switch (scenario) {
18
+ case 'duplicate_webhook':
19
+ return {
20
+ ...o,
21
+ webhookDeliveries: 2,
22
+ };
23
+ case 'out_of_order_webhook':
24
+ return {
25
+ ...o,
26
+ webhookDeliveries: 3,
27
+ };
28
+ case 'missing_webhook':
29
+ return {
30
+ ...o,
31
+ webhookDeliveries: 0,
32
+ recovered: true,
33
+ };
34
+ case 'provider_timeout_after_broadcast':
35
+ case 'late_chain_confirmation':
36
+ case 'retry_after_unknown_settlement':
37
+ return {
38
+ ...o,
39
+ providerStatus: 'timeout',
40
+ webhookDeliveries: 0,
41
+ settlementWasUnknown: true,
42
+ recovered: true,
43
+ };
44
+ case 'invalid_signature':
45
+ return {
46
+ ...o,
47
+ providerStatus: 'failed',
48
+ chainStatus: 'not_broadcast',
49
+ applicationStatus: 'none',
50
+ webhookDeliveries: 0,
51
+ ledgerEntries: 0,
52
+ creditedAmount: 0,
53
+ webhookAccepted: false,
54
+ };
55
+ case 'late_payment':
56
+ return {
57
+ ...o,
58
+ providerStatus: 'expired',
59
+ receivedAmount: 100,
60
+ applicationStatus: 'manual_review',
61
+ ledgerEntries: 0,
62
+ creditedAmount: 0,
63
+ };
64
+ case 'overpayment':
65
+ return {
66
+ ...o,
67
+ receivedAmount: 140,
68
+ };
69
+ case 'underpayment':
70
+ return {
71
+ ...o,
72
+ providerStatus: 'underpaid',
73
+ receivedAmount: 60,
74
+ applicationStatus: 'manual_review',
75
+ ledgerEntries: 0,
76
+ creditedAmount: 0,
77
+ };
78
+ }
79
+ }
80
+ function unsafeObservation(scenario) {
81
+ const safe = safeObservation(scenario);
82
+ switch (scenario) {
83
+ case 'duplicate_webhook':
84
+ return {
85
+ ...safe,
86
+ ledgerEntries: 2,
87
+ creditedAmount: 200,
88
+ };
89
+ case 'out_of_order_webhook':
90
+ return {
91
+ ...safe,
92
+ applicationStatus: 'pending',
93
+ };
94
+ case 'missing_webhook':
95
+ return {
96
+ ...safe,
97
+ applicationStatus: 'none',
98
+ ledgerEntries: 0,
99
+ creditedAmount: 0,
100
+ };
101
+ case 'provider_timeout_after_broadcast':
102
+ case 'late_chain_confirmation':
103
+ case 'retry_after_unknown_settlement':
104
+ return {
105
+ ...safe,
106
+ retryAttempts: 1,
107
+ ledgerEntries: 2,
108
+ creditedAmount: 200,
109
+ };
110
+ case 'invalid_signature':
111
+ return {
112
+ ...safe,
113
+ applicationStatus: 'completed',
114
+ webhookDeliveries: 1,
115
+ ledgerEntries: 1,
116
+ creditedAmount: 100,
117
+ webhookAccepted: true,
118
+ };
119
+ case 'late_payment':
120
+ return {
121
+ ...safe,
122
+ applicationStatus: 'completed',
123
+ ledgerEntries: 1,
124
+ creditedAmount: 100,
125
+ };
126
+ case 'overpayment':
127
+ return {
128
+ ...safe,
129
+ receivedAmount: 140,
130
+ creditedAmount: 140,
131
+ };
132
+ case 'underpayment':
133
+ return {
134
+ ...safe,
135
+ applicationStatus: 'completed',
136
+ ledgerEntries: 1,
137
+ creditedAmount: 100,
138
+ };
139
+ }
140
+ }
141
+ export function createDemoAdapter(profile) {
142
+ return {
143
+ name: 'demo:' + profile,
144
+ async runScenario(scenario) {
145
+ return profile === 'safe'
146
+ ? safeObservation(scenario)
147
+ : unsafeObservation(scenario);
148
+ },
149
+ };
150
+ }