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/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createDemoAdapter } from './adapters/demo.js';
4
+ import { createHttpDemoAdapter } from './adapters/http-demo.js';
5
+ import { createConfiguredHttpAdapter } from './adapters/configured-http.js';
6
+ import { startDemoPaymentApp } from './core/demo-payment-app.js';
7
+ import { loadConfig } from './config.js';
8
+ import { runSuite } from './core/runner.js';
9
+ import { writeJUnit } from './reporters/junit.js';
10
+ import { scenarios } from './scenarios/builtins.js';
11
+ const program = new Command();
12
+ function printResults(adapterName, results) {
13
+ console.log('Stablecoin Reliability CI');
14
+ console.log('=========================');
15
+ console.log('Adapter: ' + adapterName);
16
+ console.log('');
17
+ for (const result of results) {
18
+ console.log((result.passed ? 'PASS' : 'FAIL') +
19
+ ' ' +
20
+ result.scenario.name);
21
+ for (const invariant of result.invariants.filter((x) => !x.passed)) {
22
+ console.log(' ' + invariant.name);
23
+ if (invariant.message) {
24
+ console.log(' ' + invariant.message);
25
+ }
26
+ }
27
+ }
28
+ const passed = results.filter((result) => result.passed).length;
29
+ const failed = results.length - passed;
30
+ console.log('');
31
+ console.log(passed + ' passed, ' + failed + ' failed');
32
+ }
33
+ program
34
+ .name('stable-ci')
35
+ .description('Reliability CI for stablecoin payment integrations')
36
+ .version('0.1.0');
37
+ program
38
+ .command('scenarios')
39
+ .description('List built-in reliability scenarios')
40
+ .action(() => {
41
+ console.log('Built-in scenarios:');
42
+ for (const scenario of scenarios) {
43
+ console.log(' ' + scenario.name);
44
+ console.log(' ' + scenario.description);
45
+ console.log(' Risk: ' + scenario.risk);
46
+ }
47
+ });
48
+ program
49
+ .command('test')
50
+ .description('Run the in-memory reliability test suite')
51
+ .option('--profile <profile>', 'Demo profile: safe or unsafe', 'safe')
52
+ .option('--json', 'Output JSON')
53
+ .action(async (options) => {
54
+ if (options.profile !== 'safe' && options.profile !== 'unsafe') {
55
+ console.error('Profile must be safe or unsafe.');
56
+ process.exitCode = 2;
57
+ return;
58
+ }
59
+ const adapter = createDemoAdapter(options.profile);
60
+ const results = await runSuite(adapter);
61
+ if (options.json) {
62
+ console.log(JSON.stringify({
63
+ adapter: adapter.name,
64
+ results,
65
+ }, null, 2));
66
+ }
67
+ else {
68
+ printResults(adapter.name, results);
69
+ }
70
+ if (results.some((result) => !result.passed)) {
71
+ process.exitCode = 1;
72
+ }
73
+ });
74
+ program
75
+ .command('test-http')
76
+ .description('Run reliability scenarios using real local HTTP fault injection')
77
+ .option('--profile <profile>', 'HTTP demo profile: safe or unsafe', 'safe')
78
+ .option('--json', 'Output JSON')
79
+ .action(async (options) => {
80
+ if (options.profile !== 'safe' && options.profile !== 'unsafe') {
81
+ console.error('Profile must be safe or unsafe.');
82
+ process.exitCode = 2;
83
+ return;
84
+ }
85
+ const adapter = createHttpDemoAdapter(options.profile);
86
+ const results = await runSuite(adapter);
87
+ if (options.json) {
88
+ console.log(JSON.stringify({
89
+ adapter: adapter.name,
90
+ results,
91
+ }, null, 2));
92
+ }
93
+ else {
94
+ printResults(adapter.name, results);
95
+ }
96
+ if (results.some((result) => !result.passed)) {
97
+ process.exitCode = 1;
98
+ }
99
+ });
100
+ program
101
+ .command('demo-server')
102
+ .description('Run the local demo payment application')
103
+ .option('--profile <profile>', 'Demo profile: safe or unsafe', 'safe')
104
+ .option('--port <port>', 'Port', '4310')
105
+ .action(async (options) => {
106
+ if (options.profile !== 'safe' && options.profile !== 'unsafe') {
107
+ console.error('Profile must be safe or unsafe.');
108
+ process.exitCode = 2;
109
+ return;
110
+ }
111
+ const port = Number(options.port);
112
+ if (!Number.isInteger(port) || port <= 0) {
113
+ console.error('Port must be a positive integer.');
114
+ process.exitCode = 2;
115
+ return;
116
+ }
117
+ const app = await startDemoPaymentApp(options.profile, port);
118
+ console.log('Demo payment app running at ' + app.baseUrl);
119
+ console.log('Profile: ' + options.profile);
120
+ console.log('Press Ctrl+C to stop.');
121
+ await new Promise((resolve) => {
122
+ let closing = false;
123
+ const shutdown = async () => {
124
+ if (closing) {
125
+ return;
126
+ }
127
+ closing = true;
128
+ await app.close();
129
+ resolve();
130
+ };
131
+ process.once('SIGINT', () => {
132
+ void shutdown();
133
+ });
134
+ process.once('SIGTERM', () => {
135
+ void shutdown();
136
+ });
137
+ });
138
+ });
139
+ program
140
+ .command('run')
141
+ .description('Run stable-ci against a configured payment application')
142
+ .option('-c, --config <path>', 'Path to stable-ci config', 'stable-ci.yml')
143
+ .option('--json', 'Output JSON')
144
+ .option('--junit <path>', 'Write JUnit XML report')
145
+ .action(async (options) => {
146
+ try {
147
+ const config = loadConfig(options.config);
148
+ const adapter = createConfiguredHttpAdapter(config);
149
+ const selectedScenarios = scenarios
150
+ .filter((scenario) => config.scenarios.includes(scenario.name))
151
+ .map((scenario) => ({
152
+ ...scenario,
153
+ expected: {
154
+ ...scenario.expected,
155
+ ...(config.expectations?.[scenario.name] ?? {}),
156
+ },
157
+ }));
158
+ const results = await runSuite(adapter, selectedScenarios);
159
+ if (options.json) {
160
+ console.log(JSON.stringify({
161
+ adapter: adapter.name,
162
+ results,
163
+ }, null, 2));
164
+ }
165
+ else {
166
+ printResults(adapter.name, results);
167
+ }
168
+ if (options.junit) {
169
+ writeJUnit(options.junit, adapter.name, results);
170
+ console.log('JUnit report: ' + options.junit);
171
+ }
172
+ if (results.some((result) => !result.passed)) {
173
+ process.exitCode = 1;
174
+ }
175
+ }
176
+ catch (error) {
177
+ console.error(error instanceof Error
178
+ ? error.message
179
+ : 'Unknown stable-ci error.');
180
+ process.exitCode = 2;
181
+ }
182
+ });
183
+ program.parseAsync();
@@ -0,0 +1,138 @@
1
+ const moneyEqual = (a, b) => Math.abs(a - b) < 0.005;
2
+ function expectedStatuses(expected) {
3
+ if (expected.applicationStatus === undefined) {
4
+ return undefined;
5
+ }
6
+ return Array.isArray(expected.applicationStatus)
7
+ ? expected.applicationStatus
8
+ : [expected.applicationStatus];
9
+ }
10
+ export function evaluateInvariants(o, expected) {
11
+ const results = [];
12
+ if (expected.ledgerEntries !== undefined) {
13
+ const passed = o.ledgerEntries === expected.ledgerEntries;
14
+ results.push({
15
+ name: expected.ledgerEntries === 1
16
+ ? 'payment_posts_exactly_once'
17
+ : 'expected_ledger_entries',
18
+ passed,
19
+ message: passed
20
+ ? undefined
21
+ : 'Expected ' +
22
+ expected.ledgerEntries +
23
+ ' ledger entry/entries, observed ' +
24
+ o.ledgerEntries +
25
+ '.'
26
+ });
27
+ }
28
+ if (expected.credit === 'exact_expected') {
29
+ const passed = moneyEqual(o.creditedAmount, o.expectedAmount);
30
+ results.push({
31
+ name: 'ledger_delta_equals_settlement_amount',
32
+ passed,
33
+ message: passed
34
+ ? undefined
35
+ : 'Expected USD ' +
36
+ o.expectedAmount.toFixed(2) +
37
+ ', ledger credited USD ' +
38
+ o.creditedAmount.toFixed(2) +
39
+ '.'
40
+ });
41
+ }
42
+ if (expected.credit === 'none') {
43
+ const passed = moneyEqual(o.creditedAmount, 0);
44
+ results.push({
45
+ name: 'failed_payment_never_credits_balance',
46
+ passed,
47
+ message: passed
48
+ ? undefined
49
+ : 'Expected no credit, but USD ' +
50
+ o.creditedAmount.toFixed(2) +
51
+ ' was credited.'
52
+ });
53
+ }
54
+ if (expected.credit === 'received_amount') {
55
+ const hasReceivedAmount = o.receivedAmount !== undefined;
56
+ const passed = hasReceivedAmount &&
57
+ moneyEqual(o.creditedAmount, o.receivedAmount);
58
+ results.push({
59
+ name: 'ledger_delta_equals_received_amount',
60
+ passed,
61
+ message: passed
62
+ ? undefined
63
+ : hasReceivedAmount
64
+ ? 'Expected received USD ' +
65
+ o.receivedAmount.toFixed(2) +
66
+ ', ledger credited USD ' +
67
+ o.creditedAmount.toFixed(2) +
68
+ '.'
69
+ : 'Scenario did not report receivedAmount.'
70
+ });
71
+ }
72
+ const allowedStatuses = expectedStatuses(expected);
73
+ if (allowedStatuses !== undefined) {
74
+ const passed = allowedStatuses.includes(o.applicationStatus);
75
+ const completedOnly = allowedStatuses.length === 1 &&
76
+ allowedStatuses[0] === 'completed';
77
+ results.push({
78
+ name: completedOnly
79
+ ? 'confirmed_payment_must_end_completed'
80
+ : 'expected_application_status',
81
+ passed,
82
+ message: passed
83
+ ? undefined
84
+ : 'Expected application state ' +
85
+ allowedStatuses.join(' or ') +
86
+ ', observed ' +
87
+ o.applicationStatus +
88
+ '.'
89
+ });
90
+ }
91
+ if (expected.retryAttempts !== undefined) {
92
+ const passed = o.retryAttempts === expected.retryAttempts;
93
+ results.push({
94
+ name: expected.retryAttempts === 0
95
+ ? 'unknown_settlement_must_not_be_retried'
96
+ : 'expected_retry_attempts',
97
+ passed,
98
+ message: passed
99
+ ? undefined
100
+ : 'Expected ' +
101
+ expected.retryAttempts +
102
+ ' retry attempt(s), observed ' +
103
+ o.retryAttempts +
104
+ '.'
105
+ });
106
+ }
107
+ if (expected.webhookAccepted !== undefined) {
108
+ const passed = o.webhookAccepted === expected.webhookAccepted;
109
+ results.push({
110
+ name: expected.webhookAccepted === false
111
+ ? 'invalid_signature_must_be_rejected'
112
+ : 'expected_webhook_acceptance',
113
+ passed,
114
+ message: passed
115
+ ? undefined
116
+ : 'Expected webhookAccepted=' +
117
+ expected.webhookAccepted +
118
+ ', observed ' +
119
+ String(o.webhookAccepted) +
120
+ '.'
121
+ });
122
+ }
123
+ if (o.scenario === 'duplicate_webhook') {
124
+ const passed = o.webhookDeliveries <= 1 ||
125
+ o.ledgerEntries <= 1;
126
+ results.push({
127
+ name: 'duplicate_webhook_must_not_duplicate_ledger',
128
+ passed,
129
+ message: passed
130
+ ? undefined
131
+ : o.webhookDeliveries +
132
+ ' webhook deliveries created ' +
133
+ o.ledgerEntries +
134
+ ' ledger entries.'
135
+ });
136
+ }
137
+ return results;
138
+ }
@@ -0,0 +1,60 @@
1
+ import { createHmac } from 'node:crypto';
2
+ function toBvnkStatus(status) {
3
+ switch (status) {
4
+ case 'pending':
5
+ return 'PROCESSING';
6
+ case 'completed':
7
+ return 'COMPLETE';
8
+ case 'failed':
9
+ case 'expired':
10
+ return 'EXPIRED';
11
+ case 'underpaid':
12
+ return 'UNDERPAID';
13
+ }
14
+ }
15
+ export function createBvnkProvider(webhookSecret = 'stable-ci-local-secret') {
16
+ return {
17
+ name: 'bvnk',
18
+ render(event) {
19
+ const eventType = event.eventType ?? 'statusChanged';
20
+ const actualAmount = event.actualAmount ?? event.amount;
21
+ const payload = {
22
+ source: 'payment',
23
+ event: eventType,
24
+ data: {
25
+ uuid: event.paymentId,
26
+ reference: event.eventId,
27
+ type: 'IN',
28
+ subType: 'merchantPayIn',
29
+ status: toBvnkStatus(event.status),
30
+ displayCurrency: {
31
+ currency: event.asset,
32
+ amount: event.amount,
33
+ actual: actualAmount,
34
+ },
35
+ paidCurrency: {
36
+ currency: event.asset,
37
+ amount: event.amount,
38
+ actual: actualAmount,
39
+ },
40
+ walletCurrency: {
41
+ currency: event.asset,
42
+ amount: event.amount,
43
+ actual: actualAmount,
44
+ },
45
+ },
46
+ };
47
+ const body = JSON.stringify(payload);
48
+ const signature = createHmac('sha256', webhookSecret)
49
+ .update(body, 'utf8')
50
+ .digest('base64');
51
+ return {
52
+ body,
53
+ headers: {
54
+ 'content-type': 'application/json',
55
+ 'x-signature': signature,
56
+ },
57
+ };
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,13 @@
1
+ export function createGenericProvider() {
2
+ return {
3
+ name: 'generic',
4
+ render(event) {
5
+ return {
6
+ body: JSON.stringify(event),
7
+ headers: {
8
+ 'content-type': 'application/json',
9
+ },
10
+ };
11
+ },
12
+ };
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ function escapeXml(value) {
4
+ return value
5
+ .replaceAll('&', '&amp;')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ .replaceAll("'", '&apos;');
10
+ }
11
+ function failureText(result) {
12
+ return result.invariants
13
+ .filter((invariant) => !invariant.passed)
14
+ .map((invariant) => {
15
+ if (invariant.message) {
16
+ return invariant.name + ': ' + invariant.message;
17
+ }
18
+ return invariant.name;
19
+ })
20
+ .join('\n');
21
+ }
22
+ export function renderJUnit(adapterName, results) {
23
+ const failures = results.filter((result) => !result.passed).length;
24
+ const testcases = results.map((result) => {
25
+ const name = escapeXml(result.scenario.name);
26
+ const className = escapeXml('stable-ci.' + adapterName);
27
+ if (result.passed) {
28
+ return [
29
+ ' <testcase',
30
+ ' classname="' + className + '"',
31
+ ' name="' + name + '"',
32
+ ' />',
33
+ ].join('\n');
34
+ }
35
+ const message = failureText(result);
36
+ return [
37
+ ' <testcase',
38
+ ' classname="' + className + '"',
39
+ ' name="' + name + '"',
40
+ ' >',
41
+ ' <failure message="Stablecoin reliability invariant failed">',
42
+ escapeXml(message),
43
+ ' </failure>',
44
+ ' </testcase>',
45
+ ].join('\n');
46
+ });
47
+ return [
48
+ '<?xml version="1.0" encoding="UTF-8"?>',
49
+ '<testsuite',
50
+ ' name="stable-ci"',
51
+ ' tests="' + results.length + '"',
52
+ ' failures="' + failures + '"',
53
+ '>',
54
+ ...testcases,
55
+ '</testsuite>',
56
+ '',
57
+ ].join('\n');
58
+ }
59
+ export function writeJUnit(filePath, adapterName, results) {
60
+ const directory = path.dirname(path.resolve(filePath));
61
+ fs.mkdirSync(directory, {
62
+ recursive: true,
63
+ });
64
+ fs.writeFileSync(filePath, renderJUnit(adapterName, results), 'utf8');
65
+ }
@@ -0,0 +1,106 @@
1
+ export const scenarios = [
2
+ {
3
+ name: 'duplicate_webhook',
4
+ description: 'The same payment webhook is delivered more than once.',
5
+ risk: 'Duplicate ledger posting or duplicate credit',
6
+ expected: {
7
+ applicationStatus: 'completed',
8
+ ledgerEntries: 1,
9
+ credit: 'exact_expected',
10
+ },
11
+ },
12
+ {
13
+ name: 'out_of_order_webhook',
14
+ description: 'Payment lifecycle events arrive out of order.',
15
+ risk: 'State regression or incorrect final payment state',
16
+ expected: {
17
+ applicationStatus: 'completed',
18
+ ledgerEntries: 1,
19
+ credit: 'exact_expected',
20
+ },
21
+ },
22
+ {
23
+ name: 'missing_webhook',
24
+ description: 'The provider completes the payment but the webhook is never delivered.',
25
+ risk: 'Confirmed payment never reaches the internal ledger',
26
+ expected: {
27
+ applicationStatus: 'completed',
28
+ ledgerEntries: 1,
29
+ credit: 'exact_expected',
30
+ },
31
+ },
32
+ {
33
+ name: 'provider_timeout_after_broadcast',
34
+ description: 'The provider times out after broadcasting the transaction.',
35
+ risk: 'Unsafe retry can create a duplicate payment',
36
+ expected: {
37
+ applicationStatus: 'completed',
38
+ ledgerEntries: 1,
39
+ credit: 'exact_expected',
40
+ retryAttempts: 0,
41
+ },
42
+ },
43
+ {
44
+ name: 'late_chain_confirmation',
45
+ description: 'The chain confirms after the application has already observed a timeout.',
46
+ risk: 'Application and blockchain disagree about settlement',
47
+ expected: {
48
+ applicationStatus: 'completed',
49
+ ledgerEntries: 1,
50
+ credit: 'exact_expected',
51
+ retryAttempts: 0,
52
+ },
53
+ },
54
+ {
55
+ name: 'retry_after_unknown_settlement',
56
+ description: 'A retry is requested while the original settlement outcome is still unknown.',
57
+ risk: 'Double payment or duplicate payout',
58
+ expected: {
59
+ applicationStatus: 'completed',
60
+ ledgerEntries: 1,
61
+ credit: 'exact_expected',
62
+ retryAttempts: 0,
63
+ },
64
+ },
65
+ {
66
+ name: 'invalid_signature',
67
+ description: 'A webhook is delivered with an invalid provider signature.',
68
+ risk: 'Forged payment events may be accepted and credited',
69
+ expected: {
70
+ applicationStatus: 'none',
71
+ ledgerEntries: 0,
72
+ credit: 'none',
73
+ webhookAccepted: false,
74
+ },
75
+ },
76
+ {
77
+ name: 'underpayment',
78
+ description: 'The customer sends less than the requested payment amount.',
79
+ risk: 'Partial payment may be incorrectly treated as fully paid',
80
+ expected: {
81
+ applicationStatus: 'manual_review',
82
+ ledgerEntries: 0,
83
+ credit: 'none',
84
+ },
85
+ },
86
+ {
87
+ name: 'overpayment',
88
+ description: 'The customer sends more than the requested payment amount.',
89
+ risk: 'Excess funds may be incorrectly credited to the main customer balance',
90
+ expected: {
91
+ applicationStatus: 'completed',
92
+ ledgerEntries: 1,
93
+ credit: 'exact_expected',
94
+ },
95
+ },
96
+ {
97
+ name: 'late_payment',
98
+ description: 'Funds arrive after the original payment has already expired.',
99
+ risk: 'Expired payment may be incorrectly revived and credited as a normal payment',
100
+ expected: {
101
+ applicationStatus: 'manual_review',
102
+ ledgerEntries: 0,
103
+ credit: 'none',
104
+ },
105
+ },
106
+ ];
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "stable-ci",
3
+ "version": "0.1.0",
4
+ "description": "Reliability CI for stablecoin payment integrations",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "dev": "tsx src/index.ts",
9
+ "test:watch": "vitest",
10
+ "build": "tsc",
11
+ "check": "npm test && npm run build",
12
+ "prepublishOnly": "npm run check"
13
+ },
14
+ "keywords": [
15
+ "stablecoin",
16
+ "payments",
17
+ "webhook",
18
+ "reliability",
19
+ "testing",
20
+ "ci",
21
+ "bvnk"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "bin": {
27
+ "stable-ci": "dist/index.js"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^15.0.0",
31
+ "yaml": "^2.9.1",
32
+ "zod": "^4.6.5"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^26.6.2",
36
+ "tsx": "^4.23.13",
37
+ "typescript": "^7.0.2",
38
+ "vitest": "^5.0.1"
39
+ },
40
+ "allowScripts": {
41
+ "esbuild@0.28.2": true
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "files": [
47
+ "dist"
48
+ ],
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/saldfsdk/stable-ci.git"
52
+ },
53
+ "homepage": "https://github.com/saldfsdk/stable-ci#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/saldfsdk/stable-ci/issues"
56
+ }
57
+ }