From d717b504a6efd16b623321a997cfaa9dc7d4e402 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 7 Jun 2026 21:51:32 -0700 Subject: [PATCH] feat(omnl): settlement terminal, compliance gates, and HYBX integration foundation Add operator settlement terminal UI/API, swift-listener service, compliance idempotency gates, GRU reserve dashboards, and @dbis/integration-foundation for typed HYBX/ISO adapter contracts. Co-authored-by: Cursor --- .gitignore | 7 + config/omnl-settlement-terminal.v1.json | 42 ++ .../mk-albert-gate-limited/.env.example | 12 + .../mk-albert-gate-limited/README.md | 46 ++ .../mk-albert-gate-limited/connection.v1.json | 59 ++ connectors/besu-cacti/connector.py | 4 +- contracts/ops/EiMatrixMainnetBatchDrop.sol | 78 +++ orchestration/portal/app.py | 5 +- orchestration/portal/app_enhanced.py | 2 +- .../dist/audit/AuditEvent.d.ts | 23 + .../dist/audit/AuditEvent.js | 45 ++ .../compliance/ComplianceDecisionEngine.d.ts | 33 + .../compliance/ComplianceDecisionEngine.js | 90 +++ .../dist/entities/RegulatedEntity.d.ts | 30 + .../dist/entities/RegulatedEntity.js | 21 + .../dist/hybx/HttpHybxClient.d.ts | 20 + .../dist/hybx/HttpHybxClient.js | 72 ++ .../dist/hybx/HybxClient.d.ts | 12 + .../dist/hybx/HybxClient.js | 2 + .../dist/hybx/MockHybxClient.d.ts | 10 + .../dist/hybx/MockHybxClient.js | 57 ++ .../dist/hybx/config.d.ts | 20 + .../dist/hybx/config.js | 66 ++ .../openapi-placeholder-contract.test.d.ts | 1 + .../hybx/openapi-placeholder-contract.test.js | 49 ++ .../dist/hybx/types.d.ts | 38 ++ .../integration-foundation/dist/hybx/types.js | 3 + .../integration-foundation/dist/index.d.ts | 14 + packages/integration-foundation/dist/index.js | 30 + .../dist/integration-foundation.test.d.ts | 1 + .../dist/integration-foundation.test.js | 231 +++++++ .../iso20022/CanonicalFinancialEvent.d.ts | 49 ++ .../dist/iso20022/CanonicalFinancialEvent.js | 39 ++ .../dist/observability/correlation.d.ts | 12 + .../dist/observability/correlation.js | 39 ++ .../reconciliation/ReconciliationStatus.d.ts | 39 ++ .../reconciliation/ReconciliationStatus.js | 54 ++ .../dist/resilience/circuitBreaker.d.ts | 16 + .../dist/resilience/circuitBreaker.js | 48 ++ .../dist/resilience/idempotency.d.ts | 13 + .../dist/resilience/idempotency.js | 28 + .../dist/webhooks/verifySignature.d.ts | 9 + .../dist/webhooks/verifySignature.js | 27 + packages/integration-foundation/package.json | 17 + .../src/audit/AuditEvent.ts | 71 ++ .../compliance/ComplianceDecisionEngine.ts | 140 ++++ .../src/entities/RegulatedEntity.ts | 71 ++ .../src/hybx/HttpHybxClient.ts | 79 +++ .../src/hybx/HybxClient.ts | 22 + .../src/hybx/MockHybxClient.ts | 67 ++ .../integration-foundation/src/hybx/config.ts | 91 +++ .../hybx/openapi-placeholder-contract.test.ts | 50 ++ .../integration-foundation/src/hybx/types.ts | 44 ++ packages/integration-foundation/src/index.ts | 14 + .../src/integration-foundation.test.ts | 261 ++++++++ .../src/iso20022/CanonicalFinancialEvent.ts | 97 +++ .../src/iso20022/mappings.md | 18 + .../src/observability/correlation.ts | 45 ++ .../reconciliation/ReconciliationStatus.ts | 99 +++ .../src/resilience/circuitBreaker.ts | 55 ++ .../src/resilience/idempotency.ts | 34 + .../src/webhooks/verifySignature.ts | 37 ++ packages/integration-foundation/tsconfig.json | 13 + scripts/configure-network-advanced.py | 13 +- scripts/configure-network-validation.py | 5 +- scripts/configure-network.py | 8 +- scripts/configure_network.py | 48 ++ scripts/configure_network_validation.py | 17 + scripts/forge/report-contract-reachability.py | 11 +- services/bridge-monitor/bridge-monitor.py | 2 +- .../parsers/iso20022_parser.py | 4 +- services/oracle-publisher/oracle_publisher.py | 4 +- .../oracle_publisher_improved.py | 8 +- services/relay/start-relay.sh | 10 + .../dist/adapters/inboundAdapters.d.ts | 18 + .../dist/adapters/inboundAdapters.d.ts.map | 1 + .../dist/adapters/inboundAdapters.js | 127 ++++ .../dist/adapters/inboundAdapters.js.map | 1 + .../dist/adapters/swiftFinTcpAdapter.d.ts | 14 + .../dist/adapters/swiftFinTcpAdapter.d.ts.map | 1 + .../dist/adapters/swiftFinTcpAdapter.js | 64 ++ .../dist/adapters/swiftFinTcpAdapter.js.map | 1 + .../dist/adapters/webhookServer.d.ts | 13 + .../dist/adapters/webhookServer.d.ts.map | 1 + .../dist/adapters/webhookServer.js | 114 ++++ .../dist/adapters/webhookServer.js.map | 1 + .../dist/canonical/mapToCanonical.d.ts | 4 + .../dist/canonical/mapToCanonical.d.ts.map | 1 + .../dist/canonical/mapToCanonical.js | 83 +++ .../dist/canonical/mapToCanonical.js.map | 1 + services/swift-listener/dist/cli.d.ts | 3 + services/swift-listener/dist/cli.d.ts.map | 1 + services/swift-listener/dist/cli.js | 68 ++ services/swift-listener/dist/cli.js.map | 1 + services/swift-listener/dist/index.d.ts | 12 + services/swift-listener/dist/index.d.ts.map | 1 + services/swift-listener/dist/index.js | 47 ++ services/swift-listener/dist/index.js.map | 1 + services/swift-listener/dist/listener.d.ts | 30 + .../swift-listener/dist/listener.d.ts.map | 1 + services/swift-listener/dist/listener.js | 289 ++++++++ services/swift-listener/dist/listener.js.map | 1 + .../dist/matchers/resourceMatcher.d.ts | 4 + .../dist/matchers/resourceMatcher.d.ts.map | 1 + .../dist/matchers/resourceMatcher.js | 103 +++ .../dist/matchers/resourceMatcher.js.map | 1 + .../dist/outbound/dispatchOutcomes.d.ts | 3 + .../dist/outbound/dispatchOutcomes.d.ts.map | 1 + .../dist/outbound/dispatchOutcomes.js | 19 + .../dist/outbound/dispatchOutcomes.js.map | 1 + .../dist/outbound/intakeGatewayAdapter.d.ts | 23 + .../outbound/intakeGatewayAdapter.d.ts.map | 1 + .../dist/outbound/intakeGatewayAdapter.js | 102 +++ .../dist/outbound/intakeGatewayAdapter.js.map | 1 + .../dist/outbound/omnlForwardAdapter.d.ts | 9 + .../dist/outbound/omnlForwardAdapter.d.ts.map | 1 + .../dist/outbound/omnlForwardAdapter.js | 104 +++ .../dist/outbound/omnlForwardAdapter.js.map | 1 + .../dist/parsers/inboundParser.d.ts | 7 + .../dist/parsers/inboundParser.d.ts.map | 1 + .../dist/parsers/inboundParser.js | 264 ++++++++ .../dist/parsers/inboundParser.js.map | 1 + .../swift-listener/dist/resourceRegistry.d.ts | 7 + .../dist/resourceRegistry.d.ts.map | 1 + .../swift-listener/dist/resourceRegistry.js | 195 ++++++ .../dist/resourceRegistry.js.map | 1 + .../swift-listener/dist/store/eventStore.d.ts | 17 + .../dist/store/eventStore.d.ts.map | 1 + .../swift-listener/dist/store/eventStore.js | 100 +++ .../dist/store/eventStore.js.map | 1 + services/swift-listener/dist/types.d.ts | 185 ++++++ services/swift-listener/dist/types.d.ts.map | 1 + services/swift-listener/dist/types.js | 3 + services/swift-listener/dist/types.js.map | 1 + services/swift-listener/package-lock.json | 364 ++++++++++ services/swift-listener/package.json | 24 + services/swift-listener/src/adapters.test.ts | 121 ++++ .../src/adapters/inboundAdapters.ts | 142 ++++ .../src/adapters/swiftFinTcpAdapter.ts | 72 ++ .../src/adapters/webhookServer.ts | 139 ++++ .../src/canonical/mapToCanonical.ts | 85 +++ services/swift-listener/src/cli.ts | 68 ++ services/swift-listener/src/index.ts | 11 + services/swift-listener/src/listener.ts | 329 +++++++++ .../src/matchers/resourceMatcher.ts | 113 ++++ .../src/outbound/dispatchOutcomes.ts | 25 + .../src/outbound/intakeGatewayAdapter.ts | 120 ++++ .../src/outbound/omnlForwardAdapter.ts | 108 +++ .../src/parsers/inboundParser.ts | 262 ++++++++ .../swift-listener/src/resourceRegistry.ts | 206 ++++++ .../swift-listener/src/store/eventStore.ts | 125 ++++ .../swift-listener/src/swift-listener.test.ts | 82 +++ services/swift-listener/src/types.ts | 210 ++++++ .../swift-listener/src/webhookServer.test.ts | 90 +++ services/swift-listener/src/wiring.test.ts | 169 +++++ services/swift-listener/tsconfig.json | 18 + services/token-aggregation/package.json | 1 + .../public/omnl-settlement-terminal.css | 190 ++++++ .../public/omnl-settlement-terminal.html | 75 +++ .../public/omnl-settlement-terminal.js | 217 ++++++ .../public/reserve-dashboard.css | 106 +++ .../public/reserve-dashboard.html | 69 ++ .../public/reserve-dashboard.js | 115 ++++ .../public/reserve-institutional.html | 74 +++ .../api/middleware/omnl-audit-middleware.ts | 12 +- .../api/middleware/omnl-compliance-gate.ts | 79 +++ .../omnl-compliance-idempotency.test.ts | 91 +++ .../src/api/middleware/omnl-idempotency.ts | 41 ++ .../src/api/routes/omnl-compliance-routes.ts | 4 + .../src/api/routes/omnl-terminal-routes.ts | 278 ++++++++ .../src/api/routes/reserve-capacity.ts | 55 ++ services/token-aggregation/src/api/server.ts | 52 ++ services/token-aggregation/src/index.ts | 2 + .../gru-reserve-capacity-scheduler.ts | 48 ++ .../src/services/gru-reserve-capacity.ts | 414 ++++++++++++ .../src/services/omnl-alchemy-client.ts | 137 ++++ .../src/services/omnl-api-catalog.ts | 13 +- .../services/omnl-settlement-terminal.test.ts | 89 +++ .../src/services/omnl-settlement-terminal.ts | 623 ++++++++++++++++++ .../src/services/omnl-terminal-connection.ts | 149 +++++ .../src/services/omnl-triple-reconcile.ts | 38 +- .../src/services/omnl-webhooks.ts | 25 +- 182 files changed, 10852 insertions(+), 40 deletions(-) create mode 100644 config/omnl-settlement-terminal.v1.json create mode 100644 config/omnl-terminal-connections/mk-albert-gate-limited/.env.example create mode 100644 config/omnl-terminal-connections/mk-albert-gate-limited/README.md create mode 100644 config/omnl-terminal-connections/mk-albert-gate-limited/connection.v1.json create mode 100644 contracts/ops/EiMatrixMainnetBatchDrop.sol create mode 100644 packages/integration-foundation/dist/audit/AuditEvent.d.ts create mode 100644 packages/integration-foundation/dist/audit/AuditEvent.js create mode 100644 packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.d.ts create mode 100644 packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.js create mode 100644 packages/integration-foundation/dist/entities/RegulatedEntity.d.ts create mode 100644 packages/integration-foundation/dist/entities/RegulatedEntity.js create mode 100644 packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts create mode 100644 packages/integration-foundation/dist/hybx/HttpHybxClient.js create mode 100644 packages/integration-foundation/dist/hybx/HybxClient.d.ts create mode 100644 packages/integration-foundation/dist/hybx/HybxClient.js create mode 100644 packages/integration-foundation/dist/hybx/MockHybxClient.d.ts create mode 100644 packages/integration-foundation/dist/hybx/MockHybxClient.js create mode 100644 packages/integration-foundation/dist/hybx/config.d.ts create mode 100644 packages/integration-foundation/dist/hybx/config.js create mode 100644 packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.d.ts create mode 100644 packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.js create mode 100644 packages/integration-foundation/dist/hybx/types.d.ts create mode 100644 packages/integration-foundation/dist/hybx/types.js create mode 100644 packages/integration-foundation/dist/index.d.ts create mode 100644 packages/integration-foundation/dist/index.js create mode 100644 packages/integration-foundation/dist/integration-foundation.test.d.ts create mode 100644 packages/integration-foundation/dist/integration-foundation.test.js create mode 100644 packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.d.ts create mode 100644 packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.js create mode 100644 packages/integration-foundation/dist/observability/correlation.d.ts create mode 100644 packages/integration-foundation/dist/observability/correlation.js create mode 100644 packages/integration-foundation/dist/reconciliation/ReconciliationStatus.d.ts create mode 100644 packages/integration-foundation/dist/reconciliation/ReconciliationStatus.js create mode 100644 packages/integration-foundation/dist/resilience/circuitBreaker.d.ts create mode 100644 packages/integration-foundation/dist/resilience/circuitBreaker.js create mode 100644 packages/integration-foundation/dist/resilience/idempotency.d.ts create mode 100644 packages/integration-foundation/dist/resilience/idempotency.js create mode 100644 packages/integration-foundation/dist/webhooks/verifySignature.d.ts create mode 100644 packages/integration-foundation/dist/webhooks/verifySignature.js create mode 100644 packages/integration-foundation/package.json create mode 100644 packages/integration-foundation/src/audit/AuditEvent.ts create mode 100644 packages/integration-foundation/src/compliance/ComplianceDecisionEngine.ts create mode 100644 packages/integration-foundation/src/entities/RegulatedEntity.ts create mode 100644 packages/integration-foundation/src/hybx/HttpHybxClient.ts create mode 100644 packages/integration-foundation/src/hybx/HybxClient.ts create mode 100644 packages/integration-foundation/src/hybx/MockHybxClient.ts create mode 100644 packages/integration-foundation/src/hybx/config.ts create mode 100644 packages/integration-foundation/src/hybx/openapi-placeholder-contract.test.ts create mode 100644 packages/integration-foundation/src/hybx/types.ts create mode 100644 packages/integration-foundation/src/index.ts create mode 100644 packages/integration-foundation/src/integration-foundation.test.ts create mode 100644 packages/integration-foundation/src/iso20022/CanonicalFinancialEvent.ts create mode 100644 packages/integration-foundation/src/iso20022/mappings.md create mode 100644 packages/integration-foundation/src/observability/correlation.ts create mode 100644 packages/integration-foundation/src/reconciliation/ReconciliationStatus.ts create mode 100644 packages/integration-foundation/src/resilience/circuitBreaker.ts create mode 100644 packages/integration-foundation/src/resilience/idempotency.ts create mode 100644 packages/integration-foundation/src/webhooks/verifySignature.ts create mode 100644 packages/integration-foundation/tsconfig.json create mode 100644 scripts/configure_network.py create mode 100644 scripts/configure_network_validation.py create mode 100644 services/swift-listener/dist/adapters/inboundAdapters.d.ts create mode 100644 services/swift-listener/dist/adapters/inboundAdapters.d.ts.map create mode 100644 services/swift-listener/dist/adapters/inboundAdapters.js create mode 100644 services/swift-listener/dist/adapters/inboundAdapters.js.map create mode 100644 services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts create mode 100644 services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts.map create mode 100644 services/swift-listener/dist/adapters/swiftFinTcpAdapter.js create mode 100644 services/swift-listener/dist/adapters/swiftFinTcpAdapter.js.map create mode 100644 services/swift-listener/dist/adapters/webhookServer.d.ts create mode 100644 services/swift-listener/dist/adapters/webhookServer.d.ts.map create mode 100644 services/swift-listener/dist/adapters/webhookServer.js create mode 100644 services/swift-listener/dist/adapters/webhookServer.js.map create mode 100644 services/swift-listener/dist/canonical/mapToCanonical.d.ts create mode 100644 services/swift-listener/dist/canonical/mapToCanonical.d.ts.map create mode 100644 services/swift-listener/dist/canonical/mapToCanonical.js create mode 100644 services/swift-listener/dist/canonical/mapToCanonical.js.map create mode 100644 services/swift-listener/dist/cli.d.ts create mode 100644 services/swift-listener/dist/cli.d.ts.map create mode 100644 services/swift-listener/dist/cli.js create mode 100644 services/swift-listener/dist/cli.js.map create mode 100644 services/swift-listener/dist/index.d.ts create mode 100644 services/swift-listener/dist/index.d.ts.map create mode 100644 services/swift-listener/dist/index.js create mode 100644 services/swift-listener/dist/index.js.map create mode 100644 services/swift-listener/dist/listener.d.ts create mode 100644 services/swift-listener/dist/listener.d.ts.map create mode 100644 services/swift-listener/dist/listener.js create mode 100644 services/swift-listener/dist/listener.js.map create mode 100644 services/swift-listener/dist/matchers/resourceMatcher.d.ts create mode 100644 services/swift-listener/dist/matchers/resourceMatcher.d.ts.map create mode 100644 services/swift-listener/dist/matchers/resourceMatcher.js create mode 100644 services/swift-listener/dist/matchers/resourceMatcher.js.map create mode 100644 services/swift-listener/dist/outbound/dispatchOutcomes.d.ts create mode 100644 services/swift-listener/dist/outbound/dispatchOutcomes.d.ts.map create mode 100644 services/swift-listener/dist/outbound/dispatchOutcomes.js create mode 100644 services/swift-listener/dist/outbound/dispatchOutcomes.js.map create mode 100644 services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts create mode 100644 services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts.map create mode 100644 services/swift-listener/dist/outbound/intakeGatewayAdapter.js create mode 100644 services/swift-listener/dist/outbound/intakeGatewayAdapter.js.map create mode 100644 services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts create mode 100644 services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts.map create mode 100644 services/swift-listener/dist/outbound/omnlForwardAdapter.js create mode 100644 services/swift-listener/dist/outbound/omnlForwardAdapter.js.map create mode 100644 services/swift-listener/dist/parsers/inboundParser.d.ts create mode 100644 services/swift-listener/dist/parsers/inboundParser.d.ts.map create mode 100644 services/swift-listener/dist/parsers/inboundParser.js create mode 100644 services/swift-listener/dist/parsers/inboundParser.js.map create mode 100644 services/swift-listener/dist/resourceRegistry.d.ts create mode 100644 services/swift-listener/dist/resourceRegistry.d.ts.map create mode 100644 services/swift-listener/dist/resourceRegistry.js create mode 100644 services/swift-listener/dist/resourceRegistry.js.map create mode 100644 services/swift-listener/dist/store/eventStore.d.ts create mode 100644 services/swift-listener/dist/store/eventStore.d.ts.map create mode 100644 services/swift-listener/dist/store/eventStore.js create mode 100644 services/swift-listener/dist/store/eventStore.js.map create mode 100644 services/swift-listener/dist/types.d.ts create mode 100644 services/swift-listener/dist/types.d.ts.map create mode 100644 services/swift-listener/dist/types.js create mode 100644 services/swift-listener/dist/types.js.map create mode 100644 services/swift-listener/package-lock.json create mode 100644 services/swift-listener/package.json create mode 100644 services/swift-listener/src/adapters.test.ts create mode 100644 services/swift-listener/src/adapters/inboundAdapters.ts create mode 100644 services/swift-listener/src/adapters/swiftFinTcpAdapter.ts create mode 100644 services/swift-listener/src/adapters/webhookServer.ts create mode 100644 services/swift-listener/src/canonical/mapToCanonical.ts create mode 100644 services/swift-listener/src/cli.ts create mode 100644 services/swift-listener/src/index.ts create mode 100644 services/swift-listener/src/listener.ts create mode 100644 services/swift-listener/src/matchers/resourceMatcher.ts create mode 100644 services/swift-listener/src/outbound/dispatchOutcomes.ts create mode 100644 services/swift-listener/src/outbound/intakeGatewayAdapter.ts create mode 100644 services/swift-listener/src/outbound/omnlForwardAdapter.ts create mode 100644 services/swift-listener/src/parsers/inboundParser.ts create mode 100644 services/swift-listener/src/resourceRegistry.ts create mode 100644 services/swift-listener/src/store/eventStore.ts create mode 100644 services/swift-listener/src/swift-listener.test.ts create mode 100644 services/swift-listener/src/types.ts create mode 100644 services/swift-listener/src/webhookServer.test.ts create mode 100644 services/swift-listener/src/wiring.test.ts create mode 100644 services/swift-listener/tsconfig.json create mode 100644 services/token-aggregation/public/omnl-settlement-terminal.css create mode 100644 services/token-aggregation/public/omnl-settlement-terminal.html create mode 100644 services/token-aggregation/public/omnl-settlement-terminal.js create mode 100644 services/token-aggregation/public/reserve-dashboard.css create mode 100644 services/token-aggregation/public/reserve-dashboard.html create mode 100644 services/token-aggregation/public/reserve-dashboard.js create mode 100644 services/token-aggregation/public/reserve-institutional.html create mode 100644 services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts create mode 100644 services/token-aggregation/src/api/middleware/omnl-compliance-idempotency.test.ts create mode 100644 services/token-aggregation/src/api/middleware/omnl-idempotency.ts create mode 100644 services/token-aggregation/src/api/routes/omnl-terminal-routes.ts create mode 100644 services/token-aggregation/src/api/routes/reserve-capacity.ts create mode 100644 services/token-aggregation/src/services/gru-reserve-capacity-scheduler.ts create mode 100644 services/token-aggregation/src/services/gru-reserve-capacity.ts create mode 100644 services/token-aggregation/src/services/omnl-alchemy-client.ts create mode 100644 services/token-aggregation/src/services/omnl-settlement-terminal.test.ts create mode 100644 services/token-aggregation/src/services/omnl-settlement-terminal.ts create mode 100644 services/token-aggregation/src/services/omnl-terminal-connection.ts diff --git a/.gitignore b/.gitignore index 2dc842f..4d50296 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ terraform.tfvars kubeconfig *.kubeconfig +# Python virtualenvs +.venv/ + +# OMNL terminal connection secrets / CIS source PDFs +config/omnl-terminal-connections/**/source/ +config/omnl-terminal-connections/**/.env + # Keys and Secrets *.key *.pem diff --git a/config/omnl-settlement-terminal.v1.json b/config/omnl-settlement-terminal.v1.json new file mode 100644 index 0000000..1935877 --- /dev/null +++ b/config/omnl-settlement-terminal.v1.json @@ -0,0 +1,42 @@ +{ + "$schema": "OMNL settlement terminal — operator document defaults", + "version": "1.0.0", + "defaultSettlementRef": "HYBX-BATCH-001", + "defaultOfficeId": 1, + "defaultCurrency": "USD", + "defaultValueDate": "2026-03-17", + "defaultBeneficiary": "Bank Kanaya (Indonesia)", + "defaultBeneficiaryOfficeId": 22, + "defaultBeneficiaryExternalId": "BANK-KANAYA-ID", + "defaultConnectionId": "mk-albert-gate-limited", + "omnlLegalName": "ORGANISATION MONDIALE DU NUMERIQUE L.P.B.C.", + "omnlLei": "98450070C57395F6B906", + "pofPrimaryGlCode": "2100", + "debitNoteDefaultDebitGl": "2100", + "debitNoteDefaultCreditGl": "1410", + "tokenization": { + "chain138RpcEnv": "RPC_URL_138", + "defaultLineId": "USD-M1", + "sanitizedRedactFields": ["accountNumber", "iban", "swiftBic", "beneficiaryName", "orderingName"] + }, + "alchemy": { + "mainnetNetwork": "eth-mainnet", + "chain138Network": "defi-oracle-meta-mainnet", + "allowedRpcMethods": [ + "eth_blockNumber", + "eth_getBalance", + "eth_call", + "eth_estimateGas", + "eth_gasPrice", + "eth_getTransactionCount", + "eth_getTransactionReceipt", + "eth_getTransactionByHash" + ], + "pricesApiPath": "/prices/v1/tokens/by-address" + }, + "disclaimers": { + "terminalScreen": "Operator terminal copy bound to Fineract SoR and ISO 20022 store — not a SWIFT NET transmission.", + "pof": "M1 central-bank liability stock (GL 2100) — not unrestricted cash or nostro.", + "swiftCopy": "Internal conversion/remittance copy for audit — not MT103/MT202 wire confirmation." + } +} diff --git a/config/omnl-terminal-connections/mk-albert-gate-limited/.env.example b/config/omnl-terminal-connections/mk-albert-gate-limited/.env.example new file mode 100644 index 0000000..9fd0e42 --- /dev/null +++ b/config/omnl-terminal-connections/mk-albert-gate-limited/.env.example @@ -0,0 +1,12 @@ +# MK ALBERT GATE LIMITED — first receiver connection (from CIS 2026-05-29) +# Copy to .env in this directory; never commit .env + +# Alchemy (Ethereum mainnet) — from CIS API ENDPOINT +MK_ALBERT_GATE_ALCHEMY_API_KEY= + +# Receiver payment gateway — from CIS API Base URL + Auth Key +MK_ALBERT_GATE_RECEIVER_API_BASE_URL=https://banktransfer.devmindgroup.com/api +MK_ALBERT_GATE_RECEIVER_API_AUTH_KEY= + +# Optional: override project dir (defaults to this folder) +# OMNL_TERMINAL_CONNECTION_DIR=/home/intlc/projects/proxmox/config/omnl-terminal-connections/mk-albert-gate-limited diff --git a/config/omnl-terminal-connections/mk-albert-gate-limited/README.md b/config/omnl-terminal-connections/mk-albert-gate-limited/README.md new file mode 100644 index 0000000..5602cbe --- /dev/null +++ b/config/omnl-terminal-connections/mk-albert-gate-limited/README.md @@ -0,0 +1,46 @@ +# MK Albert Gate Limited — settlement terminal connection + +First API + receiver profile for the OMNL/HYBX settlement terminal, sourced from the CIS (Client Information Sheet) dated **2026-05-29**. + +## Project directory + +``` +config/omnl-terminal-connections/mk-albert-gate-limited/ +├── connection.v1.json # Public metadata (no secrets) +├── .env.example # Secret env var names +├── .env # Operator copy (gitignored) +└── source/ # CIS PDF (gitignored) +``` + +## Terminal usage + +```bash +# Load connection in API calls +curl -sS "http://127.0.0.1:3000/api/v1/omnl/terminal/package?connectionId=mk-albert-gate-limited" + +# UI with connection pre-selected +/omnl/terminal?connectionId=mk-albert-gate-limited +``` + +## Secrets (operator only) + +Set in this directory's `.env` or repo root `.env`: + +| Variable | CIS field | +|----------|-----------| +| `MK_ALBERT_GATE_ALCHEMY_API_KEY` | Alchemy API ENDPOINT key | +| `MK_ALBERT_GATE_RECEIVER_API_BASE_URL` | API Base URL | +| `MK_ALBERT_GATE_RECEIVER_API_AUTH_KEY` | API Auth Key (TLS/SSL) | + +## Receiver + +| Field | Value | +|-------|--------| +| Server | DEV-CORE-PAY-GW-01 (Receiving Gateway) | +| IP | 15.204.109.60 | +| Wallet | `0xb3B416BdE671c256aAbFB56358baD91D46BB9c39` | +| Bank | UBS London — IBAN `GB26UBSW23232354681401`, SWIFT `UBSWGB2LXXX` | + +## Session index + +Operator session pointer: `terminals/mk-albert-gate-limited.session.json` (repo `reports/status/`). diff --git a/config/omnl-terminal-connections/mk-albert-gate-limited/connection.v1.json b/config/omnl-terminal-connections/mk-albert-gate-limited/connection.v1.json new file mode 100644 index 0000000..772a05b --- /dev/null +++ b/config/omnl-terminal-connections/mk-albert-gate-limited/connection.v1.json @@ -0,0 +1,59 @@ +{ + "$schema": "OMNL settlement terminal — receiver / API connection profile (no secrets)", + "connectionId": "mk-albert-gate-limited", + "version": "1.0.0", + "cisDate": "2026-05-29", + "cisSource": "source/(CIS) CLIENT INFORMATION SHEET MK ALBERT GATE LIMITED..pdf", + "client": { + "companyName": "MK ALBERT GATE LIMITED", + "companyRegistrationNo": "102271", + "address": "2nd Floor International House, 41 The Parade, St Helier, Jersey JE2 3QQ", + "jurisdiction": "JE", + "representedBy": { + "name": "Khashoggi Mot Asem Almot Azbellahh", + "title": "CEO", + "passportNo": "T075922", + "passportCountry": "SA", + "passportIssue": "2021-04-30", + "passportExpiry": "2031-03-31" + } + }, + "bank": { + "name": "UBS London UK", + "address": "AG London UK", + "accountName": "MK ALBERT GATE LIMITED", + "accountNumber": "23232354681401", + "iban": "GB26UBSW23232354681401", + "swiftBic": "UBSWGB2LXXX" + }, + "receiver": { + "serverId": "DEV-CORE-PAY-GW-01", + "serverLabel": "Receiving Gateway", + "receiverIp": "15.204.109.60", + "protocol": "HTTPS REST API JSON", + "port": 443, + "tls": true, + "apiBaseUrlEnv": "MK_ALBERT_GATE_RECEIVER_API_BASE_URL", + "apiAuthKeyEnv": "MK_ALBERT_GATE_RECEIVER_API_AUTH_KEY", + "apiDocsUrl": "https://banktransfer.devmindgroup.com/api/docs" + }, + "alchemy": { + "network": "eth-mainnet", + "rpcUrlTemplate": "https://eth-mainnet.g.alchemy.com/v2/{ALCHEMY_API_KEY}", + "apiKeyEnv": "MK_ALBERT_GATE_ALCHEMY_API_KEY", + "fallbackApiKeyEnv": "ALCHEMY_API_KEY" + }, + "wallet": { + "chain": "ethereum", + "address": "0xb3B416BdE671c256aAbFB56358baD91D46BB9c39", + "purpose": "Receive tokens and collectibles on Ethereum mainnet" + }, + "settlementDefaults": { + "settlementRef": "MK-ALBERT-GATE-20260529", + "beneficiary": "MK ALBERT GATE LIMITED", + "beneficiaryExternalId": "MK-ALBERT-GATE-LTD", + "currency": "USD" + }, + "envFile": ".env", + "projectDirEnv": "OMNL_TERMINAL_CONNECTION_DIR" +} diff --git a/connectors/besu-cacti/connector.py b/connectors/besu-cacti/connector.py index 12c8bd6..f5e846b 100644 --- a/connectors/besu-cacti/connector.py +++ b/connectors/besu-cacti/connector.py @@ -37,7 +37,7 @@ class BesuCactiConnector: response.raise_for_status() return response.json() - def deploy_contract(self, contract_abi: list, contract_bytecode: str, constructor_args: list = None) -> Dict[str, Any]: + def deploy_contract(self, contract_abi: list, contract_bytecode: str, constructor_args: Optional[list] = None) -> Dict[str, Any]: """Deploy contract via Cacti""" url = f"{self.cactus_api_url}/api/v1/plugins/ledger-connector/besu/deploy-contract" data = { @@ -49,7 +49,7 @@ class BesuCactiConnector: response.raise_for_status() return response.json() - def invoke_contract(self, contract_address: str, contract_abi: list, method: str, args: list = None) -> Dict[str, Any]: + def invoke_contract(self, contract_address: str, contract_abi: list, method: str, args: Optional[list] = None) -> Dict[str, Any]: """Invoke contract method via Cacti""" url = f"{self.cactus_api_url}/api/v1/plugins/ledger-connector/besu/invoke-contract" data = { diff --git a/contracts/ops/EiMatrixMainnetBatchDrop.sol b/contracts/ops/EiMatrixMainnetBatchDrop.sol new file mode 100644 index 0000000..5de51ec --- /dev/null +++ b/contracts/ops/EiMatrixMainnetBatchDrop.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @notice Batch ERC-20 drops for EI matrix mainnet top-ups (cWUSDT / cWUSDC). + * @dev Multicall3 transferFrom is not viable on mainnet cW* (allowance does not persist for MC3). + * Operator prefunds this contract, then calls dropPrefunded() — ~100 recipients per tx. + * msg.sender must be owner (deployer EOA). + */ +contract EiMatrixMainnetBatchDrop { + using SafeERC20 for IERC20; + + address public immutable owner; + + event BatchDrop(address indexed token, address indexed operator, uint256 recipientCount, uint256 totalAmount); + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "EiMatrixBatchDrop: not owner"); + _; + } + + /// @dev Transfer `amounts[i]` from this contract's token balance to `recipients[i]`. + function dropPrefunded( + address token, + address[] calldata recipients, + uint256[] calldata amounts + ) external onlyOwner { + uint256 n = recipients.length; + require(n == amounts.length, "EiMatrixBatchDrop: length mismatch"); + uint256 total; + for (uint256 i; i < n; ) { + uint256 amount = amounts[i]; + total += amount; + IERC20(token).safeTransfer(recipients[i], amount); + unchecked { + ++i; + } + } + emit BatchDrop(token, msg.sender, n, total); + } + + /// @dev Pull total from owner via transferFrom then distribute (one tx if allowance to this contract works). + function pullAndDrop( + address token, + address[] calldata recipients, + uint256[] calldata amounts + ) external onlyOwner { + uint256 n = recipients.length; + require(n == amounts.length, "EiMatrixBatchDrop: length mismatch"); + uint256 total; + for (uint256 i; i < n; ) { + total += amounts[i]; + unchecked { + ++i; + } + } + IERC20(token).safeTransferFrom(owner, address(this), total); + for (uint256 i; i < n; ) { + IERC20(token).safeTransfer(recipients[i], amounts[i]); + unchecked { + ++i; + } + } + emit BatchDrop(token, msg.sender, n, total); + } + + /// @dev Recover stray tokens (operator only). + function sweep(address token, address to, uint256 amount) external onlyOwner { + IERC20(token).safeTransfer(to, amount); + } +} diff --git a/orchestration/portal/app.py b/orchestration/portal/app.py index 54477ae..934df48 100644 --- a/orchestration/portal/app.py +++ b/orchestration/portal/app.py @@ -23,12 +23,13 @@ DEPLOYMENT_LOG_DIR = os.path.join(os.path.dirname(__file__), '../../logs/deploym os.makedirs(DEPLOYMENT_LOG_DIR, exist_ok=True) -def load_environments() -> Dict[str, Any]: +def load_environments() -> List[Dict[str, Any]]: """Load environments configuration from YAML file""" try: with open(ENVIRONMENTS_FILE, 'r') as f: config = yaml.safe_load(f) - return config.get('environments', []) + environments = config.get('environments', []) if isinstance(config, dict) else [] + return environments if isinstance(environments, list) else [] except Exception as e: print(f"Error loading environments: {e}") return [] diff --git a/orchestration/portal/app_enhanced.py b/orchestration/portal/app_enhanced.py index 264efe0..c52c283 100644 --- a/orchestration/portal/app_enhanced.py +++ b/orchestration/portal/app_enhanced.py @@ -156,7 +156,7 @@ def get_deployment_status(environment_name: str) -> Dict[str, Any]: return base_status -def get_metrics(environment_name: str, hours: int = 24) -> List[Dict[str, Any]]: +def get_metrics(environment_name: str, hours: int = 24) -> Dict[str, List[Dict[str, Any]]]: """Get metrics for an environment over time""" conn = sqlite3.connect(DB_FILE) c = conn.cursor() diff --git a/packages/integration-foundation/dist/audit/AuditEvent.d.ts b/packages/integration-foundation/dist/audit/AuditEvent.d.ts new file mode 100644 index 0000000..af91090 --- /dev/null +++ b/packages/integration-foundation/dist/audit/AuditEvent.d.ts @@ -0,0 +1,23 @@ +export type ActorType = 'user' | 'service' | 'system' | 'operator'; +export type AuditEvent = { + audit_event_id: string; + timestamp: string; + actor_type: ActorType; + actor_id: string; + tenant_id: string; + entity_id: string; + action: string; + resource_type: string; + resource_id: string; + correlation_id?: string; + request_id?: string; + before_hash?: string; + after_hash?: string; + decision?: string; + reason_code?: string; + metadata_redacted: Record; +}; +export declare function redactMetadata(metadata: Record): Record; +export declare function createAuditEvent(partial: Omit & { + metadata?: Record; +}): AuditEvent; diff --git a/packages/integration-foundation/dist/audit/AuditEvent.js b/packages/integration-foundation/dist/audit/AuditEvent.js new file mode 100644 index 0000000..501ee6d --- /dev/null +++ b/packages/integration-foundation/dist/audit/AuditEvent.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.redactMetadata = redactMetadata; +exports.createAuditEvent = createAuditEvent; +const SENSITIVE_KEY_PATTERNS = [ + /password/i, + /secret/i, + /token/i, + /api[_-]?key/i, + /private[_-]?key/i, + /credential/i, + /ssn/i, + /iban/i, + /email/i, + /phone/i, + /address/i, +]; +const REDACTED = '[REDACTED]'; +function isSensitiveKey(key) { + return SENSITIVE_KEY_PATTERNS.some((p) => p.test(key)); +} +function redactMetadata(metadata) { + const out = {}; + for (const [key, value] of Object.entries(metadata)) { + if (isSensitiveKey(key)) { + out[key] = REDACTED; + continue; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + out[key] = redactMetadata(value); + } + else { + out[key] = value; + } + } + return out; +} +function createAuditEvent(partial) { + const { metadata, ...rest } = partial; + return { + ...rest, + timestamp: new Date().toISOString(), + metadata_redacted: redactMetadata(metadata ?? {}), + }; +} diff --git a/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.d.ts b/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.d.ts new file mode 100644 index 0000000..360e417 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.d.ts @@ -0,0 +1,33 @@ +export type ComplianceDecision = 'allow' | 'block' | 'review' | 'hold' | 'reject'; +export type ComplianceEvaluationInput = { + entityId: string; + tenantId: string; + counterpartyId?: string; + amount: string; + currency: string; + transactionType: string; + riskHints?: string[]; +}; +export type ComplianceEvaluationResult = { + decision: ComplianceDecision; + risk_score: number; + reason_codes: string[]; + evidence_refs: string[]; + expires_at?: string; + manual_review_required: boolean; + audit_event_id: string; +}; +export interface ComplianceDecisionEngine { + evaluateTransaction(input: ComplianceEvaluationInput): Promise; + evaluateEntity(entityId: string, tenantId: string): Promise; + evaluateCounterparty(counterpartyId: string, tenantId: string): Promise; +} +export declare class MockComplianceDecisionEngine implements ComplianceDecisionEngine { + private readonly blockAmountThreshold; + constructor(options?: { + blockAmountThreshold?: number; + }); + evaluateTransaction(input: ComplianceEvaluationInput): Promise; + evaluateEntity(entityId: string, tenantId: string): Promise; + evaluateCounterparty(counterpartyId: string, tenantId: string): Promise; +} diff --git a/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.js b/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.js new file mode 100644 index 0000000..57f9e84 --- /dev/null +++ b/packages/integration-foundation/dist/compliance/ComplianceDecisionEngine.js @@ -0,0 +1,90 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MockComplianceDecisionEngine = void 0; +let auditCounter = 0; +function nextAuditId() { + auditCounter += 1; + return `audit-mock-${auditCounter}`; +} +class MockComplianceDecisionEngine { + blockAmountThreshold; + constructor(options) { + this.blockAmountThreshold = options?.blockAmountThreshold ?? 1_000_000; + } + async evaluateTransaction(input) { + const amount = Number(input.amount); + const hints = input.riskHints ?? []; + if (hints.includes('sanctions_hit')) { + return { + decision: 'block', + risk_score: 100, + reason_codes: ['SANCTIONS_HIT'], + evidence_refs: ['mock-evidence-sanctions'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + if (hints.includes('pep_match')) { + return { + decision: 'review', + risk_score: 75, + reason_codes: ['PEP_MATCH'], + evidence_refs: ['mock-evidence-pep'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + if (amount >= this.blockAmountThreshold) { + return { + decision: 'hold', + risk_score: 60, + reason_codes: ['AMOUNT_THRESHOLD'], + evidence_refs: ['mock-evidence-threshold'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + return { + decision: 'allow', + risk_score: 10, + reason_codes: ['CLEAR'], + evidence_refs: [], + manual_review_required: false, + audit_event_id: nextAuditId(), + }; + } + async evaluateEntity(entityId, tenantId) { + if (entityId.startsWith('blocked-')) { + return { + decision: 'reject', + risk_score: 90, + reason_codes: ['ENTITY_BLOCKED'], + evidence_refs: [`mock-entity-${tenantId}-${entityId}`], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + return { + decision: 'allow', + risk_score: 5, + reason_codes: ['ENTITY_CLEAR'], + evidence_refs: [], + manual_review_required: false, + audit_event_id: nextAuditId(), + }; + } + async evaluateCounterparty(counterpartyId, tenantId) { + if (counterpartyId.includes('high-risk')) { + return { + decision: 'review', + risk_score: 70, + reason_codes: ['COUNTERPARTY_HIGH_RISK'], + evidence_refs: [`mock-cp-${tenantId}`], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + return this.evaluateEntity(counterpartyId, tenantId); + } +} +exports.MockComplianceDecisionEngine = MockComplianceDecisionEngine; diff --git a/packages/integration-foundation/dist/entities/RegulatedEntity.d.ts b/packages/integration-foundation/dist/entities/RegulatedEntity.d.ts new file mode 100644 index 0000000..9e73443 --- /dev/null +++ b/packages/integration-foundation/dist/entities/RegulatedEntity.d.ts @@ -0,0 +1,30 @@ +export type EntityType = 'central_bank' | 'commercial_bank' | 'emi' | 'payment_institution' | 'custodian' | 'broker_dealer' | 'other'; +export type RegulatoryStatus = 'licensed' | 'pending' | 'suspended' | 'offboarded'; +export type RiskTier = 'low' | 'medium' | 'high' | 'prohibited'; +export type EntityCapability = 'payment_initiation' | 'settlement' | 'treasury' | 'digital_asset' | 'reporting' | 'identity' | 'reconciliation'; +export type RegulatedEntity = { + entityId: string; + tenantId: string; + legalName: string; + jurisdiction: string; + lei?: string; + regulatoryStatus: RegulatoryStatus; + entityType: EntityType; + riskTier: RiskTier; + enabledCapabilities: EntityCapability[]; + credentialRef?: string; + contractualDocRefs?: string[]; + limits?: { + dailyAmountLimit?: string; + perTransactionLimit?: string; + currency?: string; + }; + offboardedAt?: string; + metadata?: Record; +}; +export type RegulatedEntityRegistry = { + entities: RegulatedEntity[]; + getEntity(entityId: string, tenantId: string): RegulatedEntity | undefined; +}; +export declare function createInMemoryEntityRegistry(entities: RegulatedEntity[]): RegulatedEntityRegistry; +export declare function assertTenantIsolation(actorTenantId: string, resourceTenantId: string): void; diff --git a/packages/integration-foundation/dist/entities/RegulatedEntity.js b/packages/integration-foundation/dist/entities/RegulatedEntity.js new file mode 100644 index 0000000..3c4f28b --- /dev/null +++ b/packages/integration-foundation/dist/entities/RegulatedEntity.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createInMemoryEntityRegistry = createInMemoryEntityRegistry; +exports.assertTenantIsolation = assertTenantIsolation; +function createInMemoryEntityRegistry(entities) { + const index = new Map(); + for (const e of entities) { + index.set(`${e.tenantId}:${e.entityId}`, e); + } + return { + entities, + getEntity(entityId, tenantId) { + return index.get(`${tenantId}:${entityId}`); + }, + }; +} +function assertTenantIsolation(actorTenantId, resourceTenantId) { + if (actorTenantId !== resourceTenantId) { + throw new Error('Tenant isolation violation'); + } +} diff --git a/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts b/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts new file mode 100644 index 0000000..7a268ff --- /dev/null +++ b/packages/integration-foundation/dist/hybx/HttpHybxClient.d.ts @@ -0,0 +1,20 @@ +import type { HybxClient } from './HybxClient'; +import type { HybxPaymentRequest, HybxPaymentResponse, HybxSettlementEvent, HybxWebhookPayload } from './types'; +import { type HybxConfig } from './config'; +/** + * HTTP HYBX client — sandbox/staging only until official API spec is available. + * Does not implement request signing; paths are placeholders. + */ +export declare class HttpHybxClient implements HybxClient { + readonly environment: string; + private readonly config; + constructor(options?: { + config?: HybxConfig; + testMode?: boolean; + }); + private postJson; + initiatePayment(request: HybxPaymentRequest): Promise; + getPaymentStatus(paymentId: string): Promise; + listSettlementEvents(since?: string): Promise; + parseWebhookPayload(body: string): HybxWebhookPayload; +} diff --git a/packages/integration-foundation/dist/hybx/HttpHybxClient.js b/packages/integration-foundation/dist/hybx/HttpHybxClient.js new file mode 100644 index 0000000..a530821 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/HttpHybxClient.js @@ -0,0 +1,72 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.HttpHybxClient = void 0; +const config_1 = require("./config"); +/** + * HTTP HYBX client — sandbox/staging only until official API spec is available. + * Does not implement request signing; paths are placeholders. + */ +class HttpHybxClient { + environment; + config; + constructor(options) { + this.config = options?.config ?? (0, config_1.loadHybxConfig)({ testMode: options?.testMode ?? true }); + this.environment = this.config.environment; + if (this.config.environment === 'production') { + throw new Error('HttpHybxClient refuses production until official HYBX spec is integrated'); + } + } + async postJson(path, body) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); + try { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.config.apiKey, + Authorization: `Bearer ${this.config.clientSecret}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`HYBX HTTP ${res.status}: ${await res.text()}`); + } + return (await res.json()); + } + finally { + clearTimeout(timeout); + } + } + async initiatePayment(request) { + return this.postJson('/v1/payments', request); + } + async getPaymentStatus(paymentId) { + const res = await fetch(`${this.config.baseUrl}/v1/payments/${paymentId}`, { + headers: { 'X-API-Key': this.config.apiKey }, + }); + if (!res.ok) + throw new Error(`HYBX HTTP ${res.status}`); + return (await res.json()); + } + async listSettlementEvents(since) { + const q = since ? `?since=${encodeURIComponent(since)}` : ''; + const res = await fetch(`${this.config.baseUrl}/v1/settlement-events${q}`, { + headers: { 'X-API-Key': this.config.apiKey }, + }); + if (!res.ok) + throw new Error(`HYBX HTTP ${res.status}`); + return (await res.json()); + } + parseWebhookPayload(body) { + const parsed = JSON.parse(body); + return { + eventType: String(parsed.eventType ?? 'unknown'), + eventId: String(parsed.eventId ?? ''), + timestamp: String(parsed.timestamp ?? new Date().toISOString()), + data: parsed.data ?? parsed, + }; + } +} +exports.HttpHybxClient = HttpHybxClient; diff --git a/packages/integration-foundation/dist/hybx/HybxClient.d.ts b/packages/integration-foundation/dist/hybx/HybxClient.d.ts new file mode 100644 index 0000000..25722c6 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/HybxClient.d.ts @@ -0,0 +1,12 @@ +import type { HybxPaymentRequest, HybxPaymentResponse, HybxSettlementEvent, HybxWebhookPayload } from './types'; +/** + * HYBX API client contract. + * Official endpoint paths, auth scheme, and signing rules require HYBX operator documentation. + */ +export interface HybxClient { + readonly environment: string; + initiatePayment(request: HybxPaymentRequest): Promise; + getPaymentStatus(paymentId: string): Promise; + listSettlementEvents(since?: string): Promise; + parseWebhookPayload(body: string): HybxWebhookPayload; +} diff --git a/packages/integration-foundation/dist/hybx/HybxClient.js b/packages/integration-foundation/dist/hybx/HybxClient.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/HybxClient.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/packages/integration-foundation/dist/hybx/MockHybxClient.d.ts b/packages/integration-foundation/dist/hybx/MockHybxClient.d.ts new file mode 100644 index 0000000..f0d8f1b --- /dev/null +++ b/packages/integration-foundation/dist/hybx/MockHybxClient.d.ts @@ -0,0 +1,10 @@ +import type { HybxClient } from './HybxClient'; +import type { HybxPaymentRequest, HybxPaymentResponse, HybxSettlementEvent, HybxWebhookPayload } from './types'; +export declare class MockHybxClient implements HybxClient { + readonly environment: string; + constructor(environment?: string); + initiatePayment(request: HybxPaymentRequest): Promise; + getPaymentStatus(paymentId: string): Promise; + listSettlementEvents(): Promise; + parseWebhookPayload(body: string): HybxWebhookPayload; +} diff --git a/packages/integration-foundation/dist/hybx/MockHybxClient.js b/packages/integration-foundation/dist/hybx/MockHybxClient.js new file mode 100644 index 0000000..b8e1e09 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/MockHybxClient.js @@ -0,0 +1,57 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MockHybxClient = void 0; +const payments = new Map(); +class MockHybxClient { + environment; + constructor(environment = 'sandbox') { + this.environment = environment; + } + async initiatePayment(request) { + const paymentId = `mock-pay-${request.idempotencyKey.slice(0, 16)}`; + const existing = payments.get(request.idempotencyKey); + if (existing) + return existing; + const response = { + paymentId, + status: 'accepted', + uetr: `mock-uetr-${paymentId}`, + messageId: `mock-msg-${paymentId}`, + createdAt: new Date().toISOString(), + rawReference: request.endToEndId, + }; + payments.set(request.idempotencyKey, response); + payments.set(paymentId, response); + return response; + } + async getPaymentStatus(paymentId) { + const found = payments.get(paymentId); + if (!found) { + return { + paymentId, + status: 'pending', + createdAt: new Date().toISOString(), + }; + } + return found; + } + async listSettlementEvents() { + return Array.from(payments.values()).map((p) => ({ + eventId: `evt-${p.paymentId}`, + paymentId: p.paymentId, + status: p.status, + settlementDate: new Date().toISOString().slice(0, 10), + timestamp: new Date().toISOString(), + })); + } + parseWebhookPayload(body) { + const parsed = JSON.parse(body); + return { + eventType: String(parsed.eventType ?? 'payment.status'), + eventId: String(parsed.eventId ?? `wh-${Date.now()}`), + timestamp: String(parsed.timestamp ?? new Date().toISOString()), + data: parsed.data ?? parsed, + }; + } +} +exports.MockHybxClient = MockHybxClient; diff --git a/packages/integration-foundation/dist/hybx/config.d.ts b/packages/integration-foundation/dist/hybx/config.d.ts new file mode 100644 index 0000000..8e00b39 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/config.d.ts @@ -0,0 +1,20 @@ +import type { HybxEnvironment } from './types'; +export type HybxConfig = { + environment: HybxEnvironment; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + mtlsCertPath?: string; + mtlsKeyPath?: string; + webhookSecret: string; + requestTimeoutMs: number; + maxRetries: number; +}; +export type LoadHybxConfigOptions = { + /** When true, reject production-like base URLs (for local/test). */ + testMode?: boolean; + env?: NodeJS.ProcessEnv; +}; +export declare function loadHybxConfig(options?: LoadHybxConfigOptions): HybxConfig; +export declare function validateHybxConfigForLocalTest(config: HybxConfig): string[]; diff --git a/packages/integration-foundation/dist/hybx/config.js b/packages/integration-foundation/dist/hybx/config.js new file mode 100644 index 0000000..7551de9 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/config.js @@ -0,0 +1,66 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadHybxConfig = loadHybxConfig; +exports.validateHybxConfigForLocalTest = validateHybxConfigForLocalTest; +const PLACEHOLDER_PATTERNS = /^(placeholder|changeme|example|test|dummy|xxx*)$/i; +const PRODUCTION_URL_PATTERNS = /hybxfinance\.io|hybx\.(prod|production)/i; +function requireEnv(name, value) { + if (!value || value.trim() === '') { + throw new Error(`Missing required environment variable: ${name}`); + } + return value.trim(); +} +function parseEnvironment(raw) { + const v = (raw ?? 'sandbox').toLowerCase(); + if (v === 'sandbox' || v === 'staging' || v === 'production') + return v; + throw new Error(`Invalid HYBX_ENVIRONMENT: ${raw}`); +} +function loadHybxConfig(options = {}) { + const env = options.env ?? process.env; + const environment = parseEnvironment(env.HYBX_ENVIRONMENT); + const baseUrl = requireEnv('HYBX_BASE_URL', env.HYBX_BASE_URL); + if (options.testMode && PRODUCTION_URL_PATTERNS.test(baseUrl)) { + throw new Error('HYBX_BASE_URL appears production-like; use sandbox URL in test mode'); + } + if (environment === 'production' && options.testMode) { + throw new Error('HYBX_ENVIRONMENT=production is not allowed in test mode'); + } + const clientId = requireEnv('HYBX_CLIENT_ID', env.HYBX_CLIENT_ID); + const clientSecret = requireEnv('HYBX_CLIENT_SECRET', env.HYBX_CLIENT_SECRET); + const apiKey = requireEnv('HYBX_API_KEY', env.HYBX_API_KEY); + const webhookSecret = requireEnv('HYBX_WEBHOOK_SECRET', env.HYBX_WEBHOOK_SECRET); + for (const [key, val] of [ + ['HYBX_CLIENT_ID', clientId], + ['HYBX_CLIENT_SECRET', clientSecret], + ['HYBX_API_KEY', apiKey], + ['HYBX_WEBHOOK_SECRET', webhookSecret], + ]) { + if (!PLACEHOLDER_PATTERNS.test(val) && options.testMode && environment === 'sandbox') { + // Allow non-placeholder values in sandbox test mode but warn via validation helper + void key; + } + } + return { + environment, + baseUrl: baseUrl.replace(/\/$/, ''), + clientId, + clientSecret, + apiKey, + mtlsCertPath: env.HYBX_MTLS_CERT_PATH?.trim() || undefined, + mtlsKeyPath: env.HYBX_MTLS_KEY_PATH?.trim() || undefined, + webhookSecret, + requestTimeoutMs: Number(env.HYBX_REQUEST_TIMEOUT_MS ?? 30_000), + maxRetries: Number(env.HYBX_MAX_RETRIES ?? 3), + }; +} +function validateHybxConfigForLocalTest(config) { + const warnings = []; + if (config.baseUrl.startsWith('http://')) { + warnings.push('HYBX_BASE_URL uses plain HTTP'); + } + if (config.environment === 'production') { + warnings.push('HYBX_ENVIRONMENT is production'); + } + return warnings; +} diff --git a/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.d.ts b/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.js b/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.js new file mode 100644 index 0000000..ae6605f --- /dev/null +++ b/packages/integration-foundation/dist/hybx/openapi-placeholder-contract.test.js @@ -0,0 +1,49 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_test_1 = require("node:test"); +const strict_1 = __importDefault(require("node:assert/strict")); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +/** Paths used by HttpHybxClient (must match placeholder OpenAPI). */ +const HTTP_CLIENT_PATHS = [ + { method: 'POST', path: '/v1/payments' }, + { method: 'GET', pathPrefix: '/v1/payments/' }, + { method: 'GET', path: '/v1/settlement-events' }, +]; +function repoRootFromTestDir() { + // dist/hybx → proxmox root (5 levels up) + return (0, node_path_1.resolve)(__dirname, '../../../../..'); +} +function loadPlaceholderOpenApi() { + const openApiPath = (0, node_path_1.resolve)(repoRootFromTestDir(), 'docs/api/openapi-hybx-integration-placeholder.yaml'); + strict_1.default.ok((0, node_fs_1.existsSync)(openApiPath), `placeholder OpenAPI missing: ${openApiPath}`); + return (0, node_fs_1.readFileSync)(openApiPath, 'utf8'); +} +(0, node_test_1.describe)('HYBX placeholder OpenAPI contract', () => { + (0, node_test_1.it)('declares HttpHybxClient payment and settlement paths', () => { + const spec = loadPlaceholderOpenApi(); + for (const entry of HTTP_CLIENT_PATHS) { + if (entry.path) { + strict_1.default.match(spec, new RegExp(`^\\s+${entry.path.replace(/\//g, '\\/')}:\\s*$`, 'm')); + } + if (entry.pathPrefix) { + strict_1.default.match(spec, /\/v1\/payments\/\{paymentId\}:/); + } + } + }); + (0, node_test_1.it)('defines initiatePayment and getPaymentStatus operationIds', () => { + const spec = loadPlaceholderOpenApi(); + strict_1.default.match(spec, /operationId:\s*initiatePayment/); + strict_1.default.match(spec, /operationId:\s*getPaymentStatus/); + strict_1.default.match(spec, /operationId:\s*listSettlementEvents/); + }); + (0, node_test_1.it)('requires HybxPaymentRequest core fields', () => { + const spec = loadPlaceholderOpenApi(); + for (const field of ['idempotencyKey', 'entityId', 'tenantId', 'amount', 'currency']) { + strict_1.default.match(spec, new RegExp(`${field}:\\s*\\{`)); + } + }); +}); diff --git a/packages/integration-foundation/dist/hybx/types.d.ts b/packages/integration-foundation/dist/hybx/types.d.ts new file mode 100644 index 0000000..f4b5561 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/types.d.ts @@ -0,0 +1,38 @@ +/** Placeholder HYBX API types — require official HYBX documentation for production. */ +export type HybxEnvironment = 'sandbox' | 'staging' | 'production'; +export type HybxPaymentStatus = 'pending' | 'accepted' | 'rejected' | 'settled' | 'returned'; +export type HybxPaymentRequest = { + idempotencyKey: string; + entityId: string; + tenantId: string; + amount: string; + currency: string; + debtorAccountRef: string; + creditorAccountRef: string; + endToEndId?: string; + purposeCode?: string; + remittanceInformation?: string; + metadata?: Record; +}; +export type HybxPaymentResponse = { + paymentId: string; + status: HybxPaymentStatus; + uetr?: string; + messageId?: string; + createdAt: string; + rawReference?: string; +}; +export type HybxSettlementEvent = { + eventId: string; + paymentId: string; + status: HybxPaymentStatus; + settlementDate?: string; + reasonCode?: string; + timestamp: string; +}; +export type HybxWebhookPayload = { + eventType: string; + eventId: string; + timestamp: string; + data: Record; +}; diff --git a/packages/integration-foundation/dist/hybx/types.js b/packages/integration-foundation/dist/hybx/types.js new file mode 100644 index 0000000..dda6081 --- /dev/null +++ b/packages/integration-foundation/dist/hybx/types.js @@ -0,0 +1,3 @@ +"use strict"; +/** Placeholder HYBX API types — require official HYBX documentation for production. */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/packages/integration-foundation/dist/index.d.ts b/packages/integration-foundation/dist/index.d.ts new file mode 100644 index 0000000..e7bdb57 --- /dev/null +++ b/packages/integration-foundation/dist/index.d.ts @@ -0,0 +1,14 @@ +export * from './hybx/types'; +export * from './hybx/HybxClient'; +export * from './hybx/MockHybxClient'; +export * from './hybx/config'; +export * from './iso20022/CanonicalFinancialEvent'; +export * from './entities/RegulatedEntity'; +export * from './compliance/ComplianceDecisionEngine'; +export * from './audit/AuditEvent'; +export * from './resilience/idempotency'; +export * from './observability/correlation'; +export * from './webhooks/verifySignature'; +export * from './hybx/HttpHybxClient'; +export * from './reconciliation/ReconciliationStatus'; +export * from './resilience/circuitBreaker'; diff --git a/packages/integration-foundation/dist/index.js b/packages/integration-foundation/dist/index.js new file mode 100644 index 0000000..0847acc --- /dev/null +++ b/packages/integration-foundation/dist/index.js @@ -0,0 +1,30 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./hybx/types"), exports); +__exportStar(require("./hybx/HybxClient"), exports); +__exportStar(require("./hybx/MockHybxClient"), exports); +__exportStar(require("./hybx/config"), exports); +__exportStar(require("./iso20022/CanonicalFinancialEvent"), exports); +__exportStar(require("./entities/RegulatedEntity"), exports); +__exportStar(require("./compliance/ComplianceDecisionEngine"), exports); +__exportStar(require("./audit/AuditEvent"), exports); +__exportStar(require("./resilience/idempotency"), exports); +__exportStar(require("./observability/correlation"), exports); +__exportStar(require("./webhooks/verifySignature"), exports); +__exportStar(require("./hybx/HttpHybxClient"), exports); +__exportStar(require("./reconciliation/ReconciliationStatus"), exports); +__exportStar(require("./resilience/circuitBreaker"), exports); diff --git a/packages/integration-foundation/dist/integration-foundation.test.d.ts b/packages/integration-foundation/dist/integration-foundation.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/integration-foundation/dist/integration-foundation.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/integration-foundation/dist/integration-foundation.test.js b/packages/integration-foundation/dist/integration-foundation.test.js new file mode 100644 index 0000000..f4f1bb1 --- /dev/null +++ b/packages/integration-foundation/dist/integration-foundation.test.js @@ -0,0 +1,231 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_test_1 = require("node:test"); +const strict_1 = __importDefault(require("node:assert/strict")); +const MockHybxClient_1 = require("./hybx/MockHybxClient"); +const config_1 = require("./hybx/config"); +const ComplianceDecisionEngine_1 = require("./compliance/ComplianceDecisionEngine"); +const AuditEvent_1 = require("./audit/AuditEvent"); +const idempotency_1 = require("./resilience/idempotency"); +const CanonicalFinancialEvent_1 = require("./iso20022/CanonicalFinancialEvent"); +const RegulatedEntity_1 = require("./entities/RegulatedEntity"); +const verifySignature_1 = require("./webhooks/verifySignature"); +const HttpHybxClient_1 = require("./hybx/HttpHybxClient"); +const ReconciliationStatus_1 = require("./reconciliation/ReconciliationStatus"); +const circuitBreaker_1 = require("./resilience/circuitBreaker"); +(0, node_test_1.describe)('MockHybxClient', () => { + (0, node_test_1.it)('returns typed mock payment response', async () => { + const client = new MockHybxClient_1.MockHybxClient('sandbox'); + const res = await client.initiatePayment({ + idempotencyKey: 'key-001', + entityId: 'ent-1', + tenantId: 'tenant-a', + amount: '100.00', + currency: 'USD', + debtorAccountRef: 'debtor-1', + creditorAccountRef: 'creditor-1', + }); + strict_1.default.equal(res.status, 'accepted'); + strict_1.default.ok(res.paymentId.startsWith('mock-pay-')); + strict_1.default.ok(res.uetr); + }); +}); +(0, node_test_1.describe)('loadHybxConfig', () => { + (0, node_test_1.it)('loads sandbox placeholders', () => { + const cfg = (0, config_1.loadHybxConfig)({ + testMode: true, + env: { + HYBX_ENVIRONMENT: 'sandbox', + HYBX_BASE_URL: 'https://hybx-sandbox.example.invalid', + HYBX_CLIENT_ID: 'placeholder', + HYBX_CLIENT_SECRET: 'placeholder', + HYBX_API_KEY: 'placeholder', + HYBX_WEBHOOK_SECRET: 'placeholder', + }, + }); + strict_1.default.equal(cfg.environment, 'sandbox'); + }); + (0, node_test_1.it)('rejects production in test mode', () => { + strict_1.default.throws(() => (0, config_1.loadHybxConfig)({ + testMode: true, + env: { HYBX_ENVIRONMENT: 'production', HYBX_BASE_URL: 'https://x' }, + })); + }); +}); +(0, node_test_1.describe)('MockComplianceDecisionEngine', () => { + (0, node_test_1.it)('blocks sanctions hits', async () => { + const engine = new ComplianceDecisionEngine_1.MockComplianceDecisionEngine(); + const res = await engine.evaluateTransaction({ + entityId: 'e1', + tenantId: 't1', + amount: '10', + currency: 'USD', + transactionType: 'payment', + riskHints: ['sanctions_hit'], + }); + strict_1.default.equal(res.decision, 'block'); + }); + (0, node_test_1.it)('reviews PEP matches', async () => { + const engine = new ComplianceDecisionEngine_1.MockComplianceDecisionEngine(); + const res = await engine.evaluateTransaction({ + entityId: 'e1', + tenantId: 't1', + amount: '10', + currency: 'USD', + transactionType: 'payment', + riskHints: ['pep_match'], + }); + strict_1.default.equal(res.decision, 'review'); + }); +}); +(0, node_test_1.describe)('audit redaction', () => { + (0, node_test_1.it)('removes secret-like and PII-like fields', () => { + const redacted = (0, AuditEvent_1.redactMetadata)({ + api_key: 'sk-test-dummy', + email: 'user@example.com', + amount: '100', + }); + strict_1.default.equal(redacted.api_key, '[REDACTED]'); + strict_1.default.equal(redacted.email, '[REDACTED]'); + strict_1.default.equal(redacted.amount, '100'); + }); + (0, node_test_1.it)('creates audit event with redacted metadata', () => { + const evt = (0, AuditEvent_1.createAuditEvent)({ + audit_event_id: 'a1', + actor_type: 'service', + actor_id: 'svc-1', + tenant_id: 't1', + entity_id: 'e1', + action: 'payment.initiated', + resource_type: 'payment', + resource_id: 'p1', + metadata: { client_secret: 'dummy-secret' }, + }); + strict_1.default.equal(evt.metadata_redacted.client_secret, '[REDACTED]'); + }); +}); +(0, node_test_1.describe)('idempotency', () => { + (0, node_test_1.it)('returns stable keys for identical inputs', () => { + const input = { + tenantId: 't1', + entityId: 'e1', + operation: 'payment', + payload: { amount: '10' }, + }; + strict_1.default.equal((0, idempotency_1.deriveIdempotencyKey)(input), (0, idempotency_1.deriveIdempotencyKey)(input)); + }); + (0, node_test_1.it)('returns distinct keys for distinct inputs', () => { + const a = (0, idempotency_1.deriveIdempotencyKey)({ + tenantId: 't1', + entityId: 'e1', + operation: 'payment', + }); + const b = (0, idempotency_1.deriveIdempotencyKey)({ + tenantId: 't2', + entityId: 'e1', + operation: 'payment', + }); + strict_1.default.notEqual(a, b); + }); +}); +(0, node_test_1.describe)('CanonicalFinancialEvent', () => { + const valid = { + schema_version: '1.0', + message_id: 'msg-1', + end_to_end_id: 'e2e-1', + debtor: { id: 'd1' }, + creditor: { id: 'c1' }, + amount: '100.00', + currency: 'USD', + source_system: 'hybx', + target_system: 'omnl', + entity_id: 'e1', + tenant_id: 't1', + }; + (0, node_test_1.it)('validates required fields', () => { + strict_1.default.equal((0, CanonicalFinancialEvent_1.validateCanonicalFinancialEvent)(valid).valid, true); + strict_1.default.equal((0, CanonicalFinancialEvent_1.validateCanonicalFinancialEvent)({}).valid, false); + }); +}); +(0, node_test_1.describe)('tenant isolation', () => { + (0, node_test_1.it)('throws on tenant mismatch', () => { + strict_1.default.throws(() => (0, RegulatedEntity_1.assertTenantIsolation)('t1', 't2')); + }); +}); +(0, node_test_1.describe)('HttpHybxClient', () => { + (0, node_test_1.it)('refuses production environment', () => { + strict_1.default.throws(() => new HttpHybxClient_1.HttpHybxClient({ + testMode: false, + config: { + environment: 'production', + baseUrl: 'https://hybx.example.invalid', + clientId: 'p', + clientSecret: 'p', + apiKey: 'p', + webhookSecret: 'p', + requestTimeoutMs: 1000, + maxRetries: 1, + }, + }), /refuses production/); + }); + (0, node_test_1.it)('parses webhook payload shape', () => { + const client = new HttpHybxClient_1.HttpHybxClient({ + testMode: true, + config: { + environment: 'sandbox', + baseUrl: 'https://hybx-sandbox.example.invalid', + clientId: 'placeholder', + clientSecret: 'placeholder', + apiKey: 'placeholder', + webhookSecret: 'placeholder', + requestTimeoutMs: 1000, + maxRetries: 1, + }, + }); + const parsed = client.parseWebhookPayload(JSON.stringify({ eventType: 'settlement.completed', eventId: 'evt-1', data: { amount: '100' } })); + strict_1.default.equal(parsed.eventType, 'settlement.completed'); + strict_1.default.equal(parsed.eventId, 'evt-1'); + strict_1.default.equal(parsed.data.amount, '100'); + }); +}); +(0, node_test_1.describe)('MockReconciliationEngine', () => { + (0, node_test_1.it)('detects missing fineract refs', async () => { + const engine = new ReconciliationStatus_1.MockReconciliationEngine(); + const snap = await engine.reconcileTripleState({ + tenantId: 't1', + entityId: 'e1', + hybxRefs: ['ref-1'], + fineractRefs: [], + chainRefs: ['ref-1'], + }); + strict_1.default.equal(snap.status, 'breaks_found'); + strict_1.default.ok(snap.breaks.length >= 1); + }); +}); +(0, node_test_1.describe)('CircuitBreaker', () => { + (0, node_test_1.it)('opens after threshold failures', async () => { + const cb = new circuitBreaker_1.CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 60_000 }); + const fail = () => cb.execute(async () => { throw new Error('fail'); }); + await strict_1.default.rejects(fail); + await strict_1.default.rejects(fail); + strict_1.default.equal(cb.getState(), 'open'); + }); +}); +(0, node_test_1.describe)('webhook verification', () => { + (0, node_test_1.it)('verifies HMAC signature', () => { + const secret = 'test-webhook-secret-dummy'; + const payload = '{"event":"payment.settled"}'; + const sig = (0, verifySignature_1.computeWebhookSignature)(secret, payload); + strict_1.default.equal((0, verifySignature_1.verifyWebhookSignature)({ secret, payload, signature: sig }), true); + strict_1.default.equal((0, verifySignature_1.verifyWebhookSignature)({ secret, payload, signature: 'bad' }), false); + }); + (0, node_test_1.it)('verifies sha256= prefixed OMNL-style signatures', () => { + const secret = 'omnl-webhook-secret'; + const payload = '{"event":"ReserveCommitted"}'; + const sig = `sha256=${(0, verifySignature_1.computeWebhookSignature)(secret, payload)}`; + strict_1.default.equal((0, verifySignature_1.verifyWebhookSignature)({ secret, payload, signature: sig }), true); + }); +}); diff --git a/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.d.ts b/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.d.ts new file mode 100644 index 0000000..fcfd8dd --- /dev/null +++ b/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.d.ts @@ -0,0 +1,49 @@ +/** Extended canonical financial event aligned to ISO 20022 concepts (not full XML). */ +export type PartyAccount = { + id: string; + name?: string; + iban?: string; + bic?: string; + address?: string; +}; +export type FinancialParty = { + id: string; + name?: string; + lei?: string; + jurisdiction?: string; + account?: PartyAccount; +}; +export type ComplianceDecisionRef = 'allow' | 'block' | 'review' | 'hold' | 'reject' | 'pending'; +export type CanonicalFinancialEvent = { + schema_version: string; + message_id: string; + end_to_end_id: string; + transaction_id?: string; + uetr?: string; + debtor: FinancialParty; + creditor: FinancialParty; + debtor_agent?: FinancialParty; + creditor_agent?: FinancialParty; + debtor_account?: PartyAccount; + creditor_account?: PartyAccount; + amount: string; + currency: string; + settlement_date?: string; + purpose_code?: string; + remittance_information?: string; + status?: string; + reason_code?: string; + source_system: string; + target_system: string; + entity_id: string; + tenant_id: string; + risk_score?: number; + compliance_decision?: ComplianceDecisionRef; + audit_event_id?: string; + raw_reference?: string; +}; +export type CanonicalValidationResult = { + valid: boolean; + errors: string[]; +}; +export declare function validateCanonicalFinancialEvent(event: Partial): CanonicalValidationResult; diff --git a/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.js b/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.js new file mode 100644 index 0000000..9d837ce --- /dev/null +++ b/packages/integration-foundation/dist/iso20022/CanonicalFinancialEvent.js @@ -0,0 +1,39 @@ +"use strict"; +/** Extended canonical financial event aligned to ISO 20022 concepts (not full XML). */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.validateCanonicalFinancialEvent = validateCanonicalFinancialEvent; +const REQUIRED_FIELDS = [ + 'schema_version', + 'message_id', + 'end_to_end_id', + 'debtor', + 'creditor', + 'amount', + 'currency', + 'source_system', + 'target_system', + 'entity_id', + 'tenant_id', +]; +function validateCanonicalFinancialEvent(event) { + const errors = []; + for (const field of REQUIRED_FIELDS) { + const value = event[field]; + if (value === undefined || value === null || value === '') { + errors.push(`Missing required field: ${field}`); + } + } + if (event.debtor && !event.debtor.id) { + errors.push('debtor.id is required'); + } + if (event.creditor && !event.creditor.id) { + errors.push('creditor.id is required'); + } + if (event.amount !== undefined && !/^\d+(\.\d+)?$/.test(event.amount)) { + errors.push('amount must be a numeric string'); + } + if (event.currency !== undefined && !/^[A-Z]{3}$/.test(event.currency)) { + errors.push('currency must be a 3-letter ISO 4217 code'); + } + return { valid: errors.length === 0, errors }; +} diff --git a/packages/integration-foundation/dist/observability/correlation.d.ts b/packages/integration-foundation/dist/observability/correlation.d.ts new file mode 100644 index 0000000..bd8ec77 --- /dev/null +++ b/packages/integration-foundation/dist/observability/correlation.d.ts @@ -0,0 +1,12 @@ +export declare const CORRELATION_ID_HEADER = "x-correlation-id"; +export declare const REQUEST_ID_HEADER = "x-request-id"; +export declare function generateCorrelationId(): string; +export declare function generateRequestId(): string; +export type CorrelationContext = { + correlationId: string; + requestId: string; + tenantId?: string; + entityId?: string; +}; +export declare function createCorrelationContext(partial?: Partial): CorrelationContext; +export declare function correlationHeaders(ctx: CorrelationContext): Record; diff --git a/packages/integration-foundation/dist/observability/correlation.js b/packages/integration-foundation/dist/observability/correlation.js new file mode 100644 index 0000000..83df6e6 --- /dev/null +++ b/packages/integration-foundation/dist/observability/correlation.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.REQUEST_ID_HEADER = exports.CORRELATION_ID_HEADER = void 0; +exports.generateCorrelationId = generateCorrelationId; +exports.generateRequestId = generateRequestId; +exports.createCorrelationContext = createCorrelationContext; +exports.correlationHeaders = correlationHeaders; +const crypto_1 = require("crypto"); +exports.CORRELATION_ID_HEADER = 'x-correlation-id'; +exports.REQUEST_ID_HEADER = 'x-request-id'; +function generateCorrelationId() { + return (0, crypto_1.randomUUID)(); +} +function generateRequestId() { + return `req-${(0, crypto_1.randomUUID)()}`; +} +function orGenerated(value, fallback) { + const v = value?.trim(); + return v ? v : fallback(); +} +function createCorrelationContext(partial) { + return { + correlationId: orGenerated(partial?.correlationId, generateCorrelationId), + requestId: orGenerated(partial?.requestId, generateRequestId), + tenantId: partial?.tenantId, + entityId: partial?.entityId, + }; +} +function correlationHeaders(ctx) { + const headers = { + [exports.CORRELATION_ID_HEADER]: ctx.correlationId, + [exports.REQUEST_ID_HEADER]: ctx.requestId, + }; + if (ctx.tenantId) + headers['x-tenant-id'] = ctx.tenantId; + if (ctx.entityId) + headers['x-entity-id'] = ctx.entityId; + return headers; +} diff --git a/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.d.ts b/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.d.ts new file mode 100644 index 0000000..5d02ee7 --- /dev/null +++ b/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.d.ts @@ -0,0 +1,39 @@ +export type ReconciliationBreakType = 'amount_mismatch' | 'missing_event' | 'duplicate_event' | 'status_mismatch' | 'ledger_drift' | 'chain_drift'; +export type ReconciliationBreak = { + breakId: string; + type: ReconciliationBreakType; + source: 'hybx' | 'fineract' | 'chain' | 'internal'; + referenceId: string; + expected?: string; + actual?: string; + detectedAt: string; +}; +export type ReconciliationSnapshot = { + snapshotId: string; + tenantId: string; + entityId: string; + asOf: string; + hybxEventCount: number; + fineractPostingCount: number; + chainSettlementCount: number; + breaks: ReconciliationBreak[]; + status: 'matched' | 'breaks_found' | 'pending'; +}; +export interface ReconciliationEngine { + reconcileTripleState(input: { + tenantId: string; + entityId: string; + hybxRefs: string[]; + fineractRefs: string[]; + chainRefs: string[]; + }): Promise; +} +export declare class MockReconciliationEngine implements ReconciliationEngine { + reconcileTripleState(input: { + tenantId: string; + entityId: string; + hybxRefs: string[]; + fineractRefs: string[]; + chainRefs: string[]; + }): Promise; +} diff --git a/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.js b/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.js new file mode 100644 index 0000000..d3e1e55 --- /dev/null +++ b/packages/integration-foundation/dist/reconciliation/ReconciliationStatus.js @@ -0,0 +1,54 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MockReconciliationEngine = void 0; +class MockReconciliationEngine { + async reconcileTripleState(input) { + const breaks = []; + const hybxSet = new Set(input.hybxRefs); + const fineractSet = new Set(input.fineractRefs); + const chainSet = new Set(input.chainRefs); + for (const ref of input.hybxRefs) { + if (!fineractSet.has(ref)) { + breaks.push({ + breakId: `brk-fineract-${ref}`, + type: 'missing_event', + source: 'fineract', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + if (!chainSet.has(ref)) { + breaks.push({ + breakId: `brk-chain-${ref}`, + type: 'missing_event', + source: 'chain', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + } + for (const ref of input.fineractRefs) { + if (!hybxSet.has(ref)) { + breaks.push({ + breakId: `brk-hybx-${ref}`, + type: 'missing_event', + source: 'hybx', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + } + return { + snapshotId: `recon-${Date.now()}`, + tenantId: input.tenantId, + entityId: input.entityId, + asOf: new Date().toISOString(), + hybxEventCount: input.hybxRefs.length, + fineractPostingCount: input.fineractRefs.length, + chainSettlementCount: input.chainRefs.length, + breaks, + status: breaks.length === 0 ? 'matched' : 'breaks_found', + }; + } +} +exports.MockReconciliationEngine = MockReconciliationEngine; diff --git a/packages/integration-foundation/dist/resilience/circuitBreaker.d.ts b/packages/integration-foundation/dist/resilience/circuitBreaker.d.ts new file mode 100644 index 0000000..53e3bc4 --- /dev/null +++ b/packages/integration-foundation/dist/resilience/circuitBreaker.d.ts @@ -0,0 +1,16 @@ +export type CircuitState = 'closed' | 'open' | 'half_open'; +export type CircuitBreakerOptions = { + failureThreshold: number; + resetTimeoutMs: number; +}; +export declare class CircuitBreaker { + private failures; + private state; + private openedAt; + private readonly options; + constructor(options?: Partial); + getState(): CircuitState; + execute(fn: () => Promise): Promise; + private onSuccess; + private onFailure; +} diff --git a/packages/integration-foundation/dist/resilience/circuitBreaker.js b/packages/integration-foundation/dist/resilience/circuitBreaker.js new file mode 100644 index 0000000..428c331 --- /dev/null +++ b/packages/integration-foundation/dist/resilience/circuitBreaker.js @@ -0,0 +1,48 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CircuitBreaker = void 0; +class CircuitBreaker { + failures = 0; + state = 'closed'; + openedAt = 0; + options; + constructor(options) { + this.options = { + failureThreshold: options?.failureThreshold ?? 5, + resetTimeoutMs: options?.resetTimeoutMs ?? 30_000, + }; + } + getState() { + if (this.state === 'open' && Date.now() - this.openedAt >= this.options.resetTimeoutMs) { + this.state = 'half_open'; + } + return this.state; + } + async execute(fn) { + const state = this.getState(); + if (state === 'open') { + throw new Error('Circuit breaker open'); + } + try { + const result = await fn(); + this.onSuccess(); + return result; + } + catch (e) { + this.onFailure(); + throw e; + } + } + onSuccess() { + this.failures = 0; + this.state = 'closed'; + } + onFailure() { + this.failures += 1; + if (this.failures >= this.options.failureThreshold) { + this.state = 'open'; + this.openedAt = Date.now(); + } + } +} +exports.CircuitBreaker = CircuitBreaker; diff --git a/packages/integration-foundation/dist/resilience/idempotency.d.ts b/packages/integration-foundation/dist/resilience/idempotency.d.ts new file mode 100644 index 0000000..7f49830 --- /dev/null +++ b/packages/integration-foundation/dist/resilience/idempotency.d.ts @@ -0,0 +1,13 @@ +export type IdempotencyInput = { + tenantId: string; + entityId: string; + operation: string; + resourceId?: string; + payload?: Record; +}; +export declare function deriveIdempotencyKey(input: IdempotencyInput): string; +export declare class IdempotencyStore { + private readonly keys; + has(key: string): boolean; + record(key: string): boolean; +} diff --git a/packages/integration-foundation/dist/resilience/idempotency.js b/packages/integration-foundation/dist/resilience/idempotency.js new file mode 100644 index 0000000..daa74e6 --- /dev/null +++ b/packages/integration-foundation/dist/resilience/idempotency.js @@ -0,0 +1,28 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.IdempotencyStore = void 0; +exports.deriveIdempotencyKey = deriveIdempotencyKey; +const crypto_1 = require("crypto"); +function deriveIdempotencyKey(input) { + const canonical = JSON.stringify({ + tenantId: input.tenantId, + entityId: input.entityId, + operation: input.operation, + resourceId: input.resourceId ?? '', + payload: input.payload ?? {}, + }); + return (0, crypto_1.createHash)('sha256').update(canonical).digest('hex'); +} +class IdempotencyStore { + keys = new Set(); + has(key) { + return this.keys.has(key); + } + record(key) { + if (this.keys.has(key)) + return false; + this.keys.add(key); + return true; + } +} +exports.IdempotencyStore = IdempotencyStore; diff --git a/packages/integration-foundation/dist/webhooks/verifySignature.d.ts b/packages/integration-foundation/dist/webhooks/verifySignature.d.ts new file mode 100644 index 0000000..79f132a --- /dev/null +++ b/packages/integration-foundation/dist/webhooks/verifySignature.d.ts @@ -0,0 +1,9 @@ +export type WebhookVerifyOptions = { + secret: string; + payload: string; + signature: string; + algorithm?: 'sha256' | 'sha512'; + encoding?: 'hex' | 'base64'; +}; +export declare function computeWebhookSignature(secret: string, payload: string, algorithm?: 'sha256' | 'sha512', encoding?: 'hex' | 'base64'): string; +export declare function verifyWebhookSignature(options: WebhookVerifyOptions): boolean; diff --git a/packages/integration-foundation/dist/webhooks/verifySignature.js b/packages/integration-foundation/dist/webhooks/verifySignature.js new file mode 100644 index 0000000..0aaa2bb --- /dev/null +++ b/packages/integration-foundation/dist/webhooks/verifySignature.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.computeWebhookSignature = computeWebhookSignature; +exports.verifyWebhookSignature = verifyWebhookSignature; +const crypto_1 = require("crypto"); +function computeWebhookSignature(secret, payload, algorithm = 'sha256', encoding = 'hex') { + return (0, crypto_1.createHmac)(algorithm, secret).update(payload, 'utf8').digest(encoding); +} +function verifyWebhookSignature(options) { + const { secret, payload, signature, algorithm = 'sha256', encoding = 'hex' } = options; + if (!secret || !signature) + return false; + const expected = computeWebhookSignature(secret, payload, algorithm, encoding); + const provided = signature.startsWith('sha256=') || signature.startsWith('sha512=') + ? signature.split('=')[1] ?? '' + : signature; + try { + const a = Buffer.from(expected, encoding); + const b = Buffer.from(provided, encoding); + if (a.length !== b.length) + return false; + return (0, crypto_1.timingSafeEqual)(a, b); + } + catch { + return false; + } +} diff --git a/packages/integration-foundation/package.json b/packages/integration-foundation/package.json new file mode 100644 index 0000000..f3842af --- /dev/null +++ b/packages/integration-foundation/package.json @@ -0,0 +1,17 @@ +{ + "name": "@dbis/integration-foundation", + "version": "0.1.0", + "private": true, + "description": "HYBX integration foundation: adapter interfaces, ISO 20022 canonical events, compliance, audit, resilience utilities", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "node --test dist/integration-foundation.test.js dist/hybx/openapi-placeholder-contract.test.js" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } +} diff --git a/packages/integration-foundation/src/audit/AuditEvent.ts b/packages/integration-foundation/src/audit/AuditEvent.ts new file mode 100644 index 0000000..962cac8 --- /dev/null +++ b/packages/integration-foundation/src/audit/AuditEvent.ts @@ -0,0 +1,71 @@ +export type ActorType = 'user' | 'service' | 'system' | 'operator'; + +export type AuditEvent = { + audit_event_id: string; + timestamp: string; + actor_type: ActorType; + actor_id: string; + tenant_id: string; + entity_id: string; + action: string; + resource_type: string; + resource_id: string; + correlation_id?: string; + request_id?: string; + before_hash?: string; + after_hash?: string; + decision?: string; + reason_code?: string; + metadata_redacted: Record; +}; + +const SENSITIVE_KEY_PATTERNS = [ + /password/i, + /secret/i, + /token/i, + /api[_-]?key/i, + /private[_-]?key/i, + /credential/i, + /ssn/i, + /iban/i, + /email/i, + /phone/i, + /address/i, +]; + +const REDACTED = '[REDACTED]'; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((p) => p.test(key)); +} + +export function redactMetadata( + metadata: Record +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (isSensitiveKey(key)) { + out[key] = REDACTED; + continue; + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + out[key] = redactMetadata(value as Record); + } else { + out[key] = value; + } + } + return out; +} + +export function createAuditEvent( + partial: Omit & { + metadata?: Record; + } +): AuditEvent { + const { metadata, ...rest } = partial; + return { + ...rest, + timestamp: new Date().toISOString(), + metadata_redacted: redactMetadata(metadata ?? {}), + }; +} diff --git a/packages/integration-foundation/src/compliance/ComplianceDecisionEngine.ts b/packages/integration-foundation/src/compliance/ComplianceDecisionEngine.ts new file mode 100644 index 0000000..2dcbe45 --- /dev/null +++ b/packages/integration-foundation/src/compliance/ComplianceDecisionEngine.ts @@ -0,0 +1,140 @@ +export type ComplianceDecision = + | 'allow' + | 'block' + | 'review' + | 'hold' + | 'reject'; + +export type ComplianceEvaluationInput = { + entityId: string; + tenantId: string; + counterpartyId?: string; + amount: string; + currency: string; + transactionType: string; + riskHints?: string[]; +}; + +export type ComplianceEvaluationResult = { + decision: ComplianceDecision; + risk_score: number; + reason_codes: string[]; + evidence_refs: string[]; + expires_at?: string; + manual_review_required: boolean; + audit_event_id: string; +}; + +export interface ComplianceDecisionEngine { + evaluateTransaction(input: ComplianceEvaluationInput): Promise; + evaluateEntity(entityId: string, tenantId: string): Promise; + evaluateCounterparty( + counterpartyId: string, + tenantId: string + ): Promise; +} + +let auditCounter = 0; + +function nextAuditId(): string { + auditCounter += 1; + return `audit-mock-${auditCounter}`; +} + +export class MockComplianceDecisionEngine implements ComplianceDecisionEngine { + private readonly blockAmountThreshold: number; + + constructor(options?: { blockAmountThreshold?: number }) { + this.blockAmountThreshold = options?.blockAmountThreshold ?? 1_000_000; + } + + async evaluateTransaction( + input: ComplianceEvaluationInput + ): Promise { + const amount = Number(input.amount); + const hints = input.riskHints ?? []; + + if (hints.includes('sanctions_hit')) { + return { + decision: 'block', + risk_score: 100, + reason_codes: ['SANCTIONS_HIT'], + evidence_refs: ['mock-evidence-sanctions'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + + if (hints.includes('pep_match')) { + return { + decision: 'review', + risk_score: 75, + reason_codes: ['PEP_MATCH'], + evidence_refs: ['mock-evidence-pep'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + + if (amount >= this.blockAmountThreshold) { + return { + decision: 'hold', + risk_score: 60, + reason_codes: ['AMOUNT_THRESHOLD'], + evidence_refs: ['mock-evidence-threshold'], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + + return { + decision: 'allow', + risk_score: 10, + reason_codes: ['CLEAR'], + evidence_refs: [], + manual_review_required: false, + audit_event_id: nextAuditId(), + }; + } + + async evaluateEntity( + entityId: string, + tenantId: string + ): Promise { + if (entityId.startsWith('blocked-')) { + return { + decision: 'reject', + risk_score: 90, + reason_codes: ['ENTITY_BLOCKED'], + evidence_refs: [`mock-entity-${tenantId}-${entityId}`], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + return { + decision: 'allow', + risk_score: 5, + reason_codes: ['ENTITY_CLEAR'], + evidence_refs: [], + manual_review_required: false, + audit_event_id: nextAuditId(), + }; + } + + async evaluateCounterparty( + counterpartyId: string, + tenantId: string + ): Promise { + if (counterpartyId.includes('high-risk')) { + return { + decision: 'review', + risk_score: 70, + reason_codes: ['COUNTERPARTY_HIGH_RISK'], + evidence_refs: [`mock-cp-${tenantId}`], + manual_review_required: true, + audit_event_id: nextAuditId(), + }; + } + return this.evaluateEntity(counterpartyId, tenantId); + } +} diff --git a/packages/integration-foundation/src/entities/RegulatedEntity.ts b/packages/integration-foundation/src/entities/RegulatedEntity.ts new file mode 100644 index 0000000..edf2ae0 --- /dev/null +++ b/packages/integration-foundation/src/entities/RegulatedEntity.ts @@ -0,0 +1,71 @@ +export type EntityType = + | 'central_bank' + | 'commercial_bank' + | 'emi' + | 'payment_institution' + | 'custodian' + | 'broker_dealer' + | 'other'; + +export type RegulatoryStatus = 'licensed' | 'pending' | 'suspended' | 'offboarded'; + +export type RiskTier = 'low' | 'medium' | 'high' | 'prohibited'; + +export type EntityCapability = + | 'payment_initiation' + | 'settlement' + | 'treasury' + | 'digital_asset' + | 'reporting' + | 'identity' + | 'reconciliation'; + +export type RegulatedEntity = { + entityId: string; + tenantId: string; + legalName: string; + jurisdiction: string; + lei?: string; + regulatoryStatus: RegulatoryStatus; + entityType: EntityType; + riskTier: RiskTier; + enabledCapabilities: EntityCapability[]; + credentialRef?: string; + contractualDocRefs?: string[]; + limits?: { + dailyAmountLimit?: string; + perTransactionLimit?: string; + currency?: string; + }; + offboardedAt?: string; + metadata?: Record; +}; + +export type RegulatedEntityRegistry = { + entities: RegulatedEntity[]; + getEntity(entityId: string, tenantId: string): RegulatedEntity | undefined; +}; + +export function createInMemoryEntityRegistry( + entities: RegulatedEntity[] +): RegulatedEntityRegistry { + const index = new Map(); + for (const e of entities) { + index.set(`${e.tenantId}:${e.entityId}`, e); + } + return { + entities, + getEntity(entityId, tenantId) { + return index.get(`${tenantId}:${entityId}`); + }, + }; +} + +export function assertTenantIsolation( + actorTenantId: string, + resourceTenantId: string +): void { + if (actorTenantId !== resourceTenantId) { + throw new Error('Tenant isolation violation'); + } +} diff --git a/packages/integration-foundation/src/hybx/HttpHybxClient.ts b/packages/integration-foundation/src/hybx/HttpHybxClient.ts new file mode 100644 index 0000000..35da84d --- /dev/null +++ b/packages/integration-foundation/src/hybx/HttpHybxClient.ts @@ -0,0 +1,79 @@ +import type { HybxClient } from './HybxClient'; +import type { + HybxPaymentRequest, + HybxPaymentResponse, + HybxSettlementEvent, + HybxWebhookPayload, +} from './types'; +import { loadHybxConfig, type HybxConfig } from './config'; + +/** + * HTTP HYBX client — sandbox/staging only until official API spec is available. + * Does not implement request signing; paths are placeholders. + */ +export class HttpHybxClient implements HybxClient { + readonly environment: string; + private readonly config: HybxConfig; + + constructor(options?: { config?: HybxConfig; testMode?: boolean }) { + this.config = options?.config ?? loadHybxConfig({ testMode: options?.testMode ?? true }); + this.environment = this.config.environment; + if (this.config.environment === 'production') { + throw new Error('HttpHybxClient refuses production until official HYBX spec is integrated'); + } + } + + private async postJson(path: string, body: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); + try { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.config.apiKey, + Authorization: `Bearer ${this.config.clientSecret}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`HYBX HTTP ${res.status}: ${await res.text()}`); + } + return (await res.json()) as T; + } finally { + clearTimeout(timeout); + } + } + + async initiatePayment(request: HybxPaymentRequest): Promise { + return this.postJson('/v1/payments', request); + } + + async getPaymentStatus(paymentId: string): Promise { + const res = await fetch(`${this.config.baseUrl}/v1/payments/${paymentId}`, { + headers: { 'X-API-Key': this.config.apiKey }, + }); + if (!res.ok) throw new Error(`HYBX HTTP ${res.status}`); + return (await res.json()) as HybxPaymentResponse; + } + + async listSettlementEvents(since?: string): Promise { + const q = since ? `?since=${encodeURIComponent(since)}` : ''; + const res = await fetch(`${this.config.baseUrl}/v1/settlement-events${q}`, { + headers: { 'X-API-Key': this.config.apiKey }, + }); + if (!res.ok) throw new Error(`HYBX HTTP ${res.status}`); + return (await res.json()) as HybxSettlementEvent[]; + } + + parseWebhookPayload(body: string): HybxWebhookPayload { + const parsed = JSON.parse(body) as Record; + return { + eventType: String(parsed.eventType ?? 'unknown'), + eventId: String(parsed.eventId ?? ''), + timestamp: String(parsed.timestamp ?? new Date().toISOString()), + data: (parsed.data as Record) ?? parsed, + }; + } +} diff --git a/packages/integration-foundation/src/hybx/HybxClient.ts b/packages/integration-foundation/src/hybx/HybxClient.ts new file mode 100644 index 0000000..e47f6d3 --- /dev/null +++ b/packages/integration-foundation/src/hybx/HybxClient.ts @@ -0,0 +1,22 @@ +import type { + HybxPaymentRequest, + HybxPaymentResponse, + HybxSettlementEvent, + HybxWebhookPayload, +} from './types'; + +/** + * HYBX API client contract. + * Official endpoint paths, auth scheme, and signing rules require HYBX operator documentation. + */ +export interface HybxClient { + readonly environment: string; + + initiatePayment(request: HybxPaymentRequest): Promise; + + getPaymentStatus(paymentId: string): Promise; + + listSettlementEvents(since?: string): Promise; + + parseWebhookPayload(body: string): HybxWebhookPayload; +} diff --git a/packages/integration-foundation/src/hybx/MockHybxClient.ts b/packages/integration-foundation/src/hybx/MockHybxClient.ts new file mode 100644 index 0000000..e07fc3e --- /dev/null +++ b/packages/integration-foundation/src/hybx/MockHybxClient.ts @@ -0,0 +1,67 @@ +import type { HybxClient } from './HybxClient'; +import type { + HybxPaymentRequest, + HybxPaymentResponse, + HybxSettlementEvent, + HybxWebhookPayload, +} from './types'; + +const payments = new Map(); + +export class MockHybxClient implements HybxClient { + readonly environment: string; + + constructor(environment = 'sandbox') { + this.environment = environment; + } + + async initiatePayment(request: HybxPaymentRequest): Promise { + const paymentId = `mock-pay-${request.idempotencyKey.slice(0, 16)}`; + const existing = payments.get(request.idempotencyKey); + if (existing) return existing; + + const response: HybxPaymentResponse = { + paymentId, + status: 'accepted', + uetr: `mock-uetr-${paymentId}`, + messageId: `mock-msg-${paymentId}`, + createdAt: new Date().toISOString(), + rawReference: request.endToEndId, + }; + payments.set(request.idempotencyKey, response); + payments.set(paymentId, response); + return response; + } + + async getPaymentStatus(paymentId: string): Promise { + const found = payments.get(paymentId); + if (!found) { + return { + paymentId, + status: 'pending', + createdAt: new Date().toISOString(), + }; + } + return found; + } + + async listSettlementEvents(): Promise { + return Array.from(payments.values()).map((p) => ({ + eventId: `evt-${p.paymentId}`, + paymentId: p.paymentId, + status: p.status, + settlementDate: new Date().toISOString().slice(0, 10), + timestamp: new Date().toISOString(), + })); + } + + parseWebhookPayload(body: string): HybxWebhookPayload { + const parsed = JSON.parse(body) as Record; + return { + eventType: String(parsed.eventType ?? 'payment.status'), + eventId: String(parsed.eventId ?? `wh-${Date.now()}`), + timestamp: String(parsed.timestamp ?? new Date().toISOString()), + data: (parsed.data as Record) ?? parsed, + }; + } +} diff --git a/packages/integration-foundation/src/hybx/config.ts b/packages/integration-foundation/src/hybx/config.ts new file mode 100644 index 0000000..01a5f7a --- /dev/null +++ b/packages/integration-foundation/src/hybx/config.ts @@ -0,0 +1,91 @@ +import type { HybxEnvironment } from './types'; + +export type HybxConfig = { + environment: HybxEnvironment; + baseUrl: string; + clientId: string; + clientSecret: string; + apiKey: string; + mtlsCertPath?: string; + mtlsKeyPath?: string; + webhookSecret: string; + requestTimeoutMs: number; + maxRetries: number; +}; + +const PLACEHOLDER_PATTERNS = /^(placeholder|changeme|example|test|dummy|xxx*)$/i; +const PRODUCTION_URL_PATTERNS = /hybxfinance\.io|hybx\.(prod|production)/i; + +function requireEnv(name: string, value: string | undefined): string { + if (!value || value.trim() === '') { + throw new Error(`Missing required environment variable: ${name}`); + } + return value.trim(); +} + +function parseEnvironment(raw: string | undefined): HybxEnvironment { + const v = (raw ?? 'sandbox').toLowerCase(); + if (v === 'sandbox' || v === 'staging' || v === 'production') return v; + throw new Error(`Invalid HYBX_ENVIRONMENT: ${raw}`); +} + +export type LoadHybxConfigOptions = { + /** When true, reject production-like base URLs (for local/test). */ + testMode?: boolean; + env?: NodeJS.ProcessEnv; +}; + +export function loadHybxConfig(options: LoadHybxConfigOptions = {}): HybxConfig { + const env = options.env ?? process.env; + const environment = parseEnvironment(env.HYBX_ENVIRONMENT); + const baseUrl = requireEnv('HYBX_BASE_URL', env.HYBX_BASE_URL); + + if (options.testMode && PRODUCTION_URL_PATTERNS.test(baseUrl)) { + throw new Error('HYBX_BASE_URL appears production-like; use sandbox URL in test mode'); + } + + if (environment === 'production' && options.testMode) { + throw new Error('HYBX_ENVIRONMENT=production is not allowed in test mode'); + } + + const clientId = requireEnv('HYBX_CLIENT_ID', env.HYBX_CLIENT_ID); + const clientSecret = requireEnv('HYBX_CLIENT_SECRET', env.HYBX_CLIENT_SECRET); + const apiKey = requireEnv('HYBX_API_KEY', env.HYBX_API_KEY); + const webhookSecret = requireEnv('HYBX_WEBHOOK_SECRET', env.HYBX_WEBHOOK_SECRET); + + for (const [key, val] of [ + ['HYBX_CLIENT_ID', clientId], + ['HYBX_CLIENT_SECRET', clientSecret], + ['HYBX_API_KEY', apiKey], + ['HYBX_WEBHOOK_SECRET', webhookSecret], + ] as const) { + if (!PLACEHOLDER_PATTERNS.test(val) && options.testMode && environment === 'sandbox') { + // Allow non-placeholder values in sandbox test mode but warn via validation helper + void key; + } + } + + return { + environment, + baseUrl: baseUrl.replace(/\/$/, ''), + clientId, + clientSecret, + apiKey, + mtlsCertPath: env.HYBX_MTLS_CERT_PATH?.trim() || undefined, + mtlsKeyPath: env.HYBX_MTLS_KEY_PATH?.trim() || undefined, + webhookSecret, + requestTimeoutMs: Number(env.HYBX_REQUEST_TIMEOUT_MS ?? 30_000), + maxRetries: Number(env.HYBX_MAX_RETRIES ?? 3), + }; +} + +export function validateHybxConfigForLocalTest(config: HybxConfig): string[] { + const warnings: string[] = []; + if (config.baseUrl.startsWith('http://')) { + warnings.push('HYBX_BASE_URL uses plain HTTP'); + } + if (config.environment === 'production') { + warnings.push('HYBX_ENVIRONMENT is production'); + } + return warnings; +} diff --git a/packages/integration-foundation/src/hybx/openapi-placeholder-contract.test.ts b/packages/integration-foundation/src/hybx/openapi-placeholder-contract.test.ts new file mode 100644 index 0000000..209de28 --- /dev/null +++ b/packages/integration-foundation/src/hybx/openapi-placeholder-contract.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** Paths used by HttpHybxClient (must match placeholder OpenAPI). */ +const HTTP_CLIENT_PATHS = [ + { method: 'POST', path: '/v1/payments' }, + { method: 'GET', pathPrefix: '/v1/payments/' }, + { method: 'GET', path: '/v1/settlement-events' }, +]; + +function repoRootFromTestDir(): string { + // dist/hybx → proxmox root (5 levels up) + return resolve(__dirname, '../../../../..'); +} + +function loadPlaceholderOpenApi(): string { + const openApiPath = resolve(repoRootFromTestDir(), 'docs/api/openapi-hybx-integration-placeholder.yaml'); + assert.ok(existsSync(openApiPath), `placeholder OpenAPI missing: ${openApiPath}`); + return readFileSync(openApiPath, 'utf8'); +} + +describe('HYBX placeholder OpenAPI contract', () => { + it('declares HttpHybxClient payment and settlement paths', () => { + const spec = loadPlaceholderOpenApi(); + for (const entry of HTTP_CLIENT_PATHS) { + if (entry.path) { + assert.match(spec, new RegExp(`^\\s+${entry.path.replace(/\//g, '\\/')}:\\s*$`, 'm')); + } + if (entry.pathPrefix) { + assert.match(spec, /\/v1\/payments\/\{paymentId\}:/); + } + } + }); + + it('defines initiatePayment and getPaymentStatus operationIds', () => { + const spec = loadPlaceholderOpenApi(); + assert.match(spec, /operationId:\s*initiatePayment/); + assert.match(spec, /operationId:\s*getPaymentStatus/); + assert.match(spec, /operationId:\s*listSettlementEvents/); + }); + + it('requires HybxPaymentRequest core fields', () => { + const spec = loadPlaceholderOpenApi(); + for (const field of ['idempotencyKey', 'entityId', 'tenantId', 'amount', 'currency']) { + assert.match(spec, new RegExp(`${field}:\\s*\\{`)); + } + }); +}); diff --git a/packages/integration-foundation/src/hybx/types.ts b/packages/integration-foundation/src/hybx/types.ts new file mode 100644 index 0000000..57310df --- /dev/null +++ b/packages/integration-foundation/src/hybx/types.ts @@ -0,0 +1,44 @@ +/** Placeholder HYBX API types — require official HYBX documentation for production. */ + +export type HybxEnvironment = 'sandbox' | 'staging' | 'production'; + +export type HybxPaymentStatus = 'pending' | 'accepted' | 'rejected' | 'settled' | 'returned'; + +export type HybxPaymentRequest = { + idempotencyKey: string; + entityId: string; + tenantId: string; + amount: string; + currency: string; + debtorAccountRef: string; + creditorAccountRef: string; + endToEndId?: string; + purposeCode?: string; + remittanceInformation?: string; + metadata?: Record; +}; + +export type HybxPaymentResponse = { + paymentId: string; + status: HybxPaymentStatus; + uetr?: string; + messageId?: string; + createdAt: string; + rawReference?: string; +}; + +export type HybxSettlementEvent = { + eventId: string; + paymentId: string; + status: HybxPaymentStatus; + settlementDate?: string; + reasonCode?: string; + timestamp: string; +}; + +export type HybxWebhookPayload = { + eventType: string; + eventId: string; + timestamp: string; + data: Record; +}; diff --git a/packages/integration-foundation/src/index.ts b/packages/integration-foundation/src/index.ts new file mode 100644 index 0000000..e7bdb57 --- /dev/null +++ b/packages/integration-foundation/src/index.ts @@ -0,0 +1,14 @@ +export * from './hybx/types'; +export * from './hybx/HybxClient'; +export * from './hybx/MockHybxClient'; +export * from './hybx/config'; +export * from './iso20022/CanonicalFinancialEvent'; +export * from './entities/RegulatedEntity'; +export * from './compliance/ComplianceDecisionEngine'; +export * from './audit/AuditEvent'; +export * from './resilience/idempotency'; +export * from './observability/correlation'; +export * from './webhooks/verifySignature'; +export * from './hybx/HttpHybxClient'; +export * from './reconciliation/ReconciliationStatus'; +export * from './resilience/circuitBreaker'; diff --git a/packages/integration-foundation/src/integration-foundation.test.ts b/packages/integration-foundation/src/integration-foundation.test.ts new file mode 100644 index 0000000..e48cc28 --- /dev/null +++ b/packages/integration-foundation/src/integration-foundation.test.ts @@ -0,0 +1,261 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { MockHybxClient } from './hybx/MockHybxClient'; +import { loadHybxConfig } from './hybx/config'; +import { MockComplianceDecisionEngine } from './compliance/ComplianceDecisionEngine'; +import { createAuditEvent, redactMetadata } from './audit/AuditEvent'; +import { deriveIdempotencyKey } from './resilience/idempotency'; +import { + validateCanonicalFinancialEvent, + type CanonicalFinancialEvent, +} from './iso20022/CanonicalFinancialEvent'; +import { assertTenantIsolation } from './entities/RegulatedEntity'; +import { verifyWebhookSignature, computeWebhookSignature } from './webhooks/verifySignature'; +import { HttpHybxClient } from './hybx/HttpHybxClient'; +import { MockReconciliationEngine } from './reconciliation/ReconciliationStatus'; +import { CircuitBreaker } from './resilience/circuitBreaker'; + +describe('MockHybxClient', () => { + it('returns typed mock payment response', async () => { + const client = new MockHybxClient('sandbox'); + const res = await client.initiatePayment({ + idempotencyKey: 'key-001', + entityId: 'ent-1', + tenantId: 'tenant-a', + amount: '100.00', + currency: 'USD', + debtorAccountRef: 'debtor-1', + creditorAccountRef: 'creditor-1', + }); + assert.equal(res.status, 'accepted'); + assert.ok(res.paymentId.startsWith('mock-pay-')); + assert.ok(res.uetr); + }); +}); + +describe('loadHybxConfig', () => { + it('loads sandbox placeholders', () => { + const cfg = loadHybxConfig({ + testMode: true, + env: { + HYBX_ENVIRONMENT: 'sandbox', + HYBX_BASE_URL: 'https://hybx-sandbox.example.invalid', + HYBX_CLIENT_ID: 'placeholder', + HYBX_CLIENT_SECRET: 'placeholder', + HYBX_API_KEY: 'placeholder', + HYBX_WEBHOOK_SECRET: 'placeholder', + }, + }); + assert.equal(cfg.environment, 'sandbox'); + }); + + it('rejects production in test mode', () => { + assert.throws(() => + loadHybxConfig({ + testMode: true, + env: { HYBX_ENVIRONMENT: 'production', HYBX_BASE_URL: 'https://x' }, + }) + ); + }); +}); + +describe('MockComplianceDecisionEngine', () => { + it('blocks sanctions hits', async () => { + const engine = new MockComplianceDecisionEngine(); + const res = await engine.evaluateTransaction({ + entityId: 'e1', + tenantId: 't1', + amount: '10', + currency: 'USD', + transactionType: 'payment', + riskHints: ['sanctions_hit'], + }); + assert.equal(res.decision, 'block'); + }); + + it('reviews PEP matches', async () => { + const engine = new MockComplianceDecisionEngine(); + const res = await engine.evaluateTransaction({ + entityId: 'e1', + tenantId: 't1', + amount: '10', + currency: 'USD', + transactionType: 'payment', + riskHints: ['pep_match'], + }); + assert.equal(res.decision, 'review'); + }); +}); + +describe('audit redaction', () => { + it('removes secret-like and PII-like fields', () => { + const redacted = redactMetadata({ + api_key: 'sk-test-dummy', + email: 'user@example.com', + amount: '100', + }); + assert.equal(redacted.api_key, '[REDACTED]'); + assert.equal(redacted.email, '[REDACTED]'); + assert.equal(redacted.amount, '100'); + }); + + it('creates audit event with redacted metadata', () => { + const evt = createAuditEvent({ + audit_event_id: 'a1', + actor_type: 'service', + actor_id: 'svc-1', + tenant_id: 't1', + entity_id: 'e1', + action: 'payment.initiated', + resource_type: 'payment', + resource_id: 'p1', + metadata: { client_secret: 'dummy-secret' }, + }); + assert.equal(evt.metadata_redacted.client_secret, '[REDACTED]'); + }); +}); + +describe('idempotency', () => { + it('returns stable keys for identical inputs', () => { + const input = { + tenantId: 't1', + entityId: 'e1', + operation: 'payment', + payload: { amount: '10' }, + }; + assert.equal(deriveIdempotencyKey(input), deriveIdempotencyKey(input)); + }); + + it('returns distinct keys for distinct inputs', () => { + const a = deriveIdempotencyKey({ + tenantId: 't1', + entityId: 'e1', + operation: 'payment', + }); + const b = deriveIdempotencyKey({ + tenantId: 't2', + entityId: 'e1', + operation: 'payment', + }); + assert.notEqual(a, b); + }); +}); + +describe('CanonicalFinancialEvent', () => { + const valid: CanonicalFinancialEvent = { + schema_version: '1.0', + message_id: 'msg-1', + end_to_end_id: 'e2e-1', + debtor: { id: 'd1' }, + creditor: { id: 'c1' }, + amount: '100.00', + currency: 'USD', + source_system: 'hybx', + target_system: 'omnl', + entity_id: 'e1', + tenant_id: 't1', + }; + + it('validates required fields', () => { + assert.equal(validateCanonicalFinancialEvent(valid).valid, true); + assert.equal(validateCanonicalFinancialEvent({}).valid, false); + }); +}); + +describe('tenant isolation', () => { + it('throws on tenant mismatch', () => { + assert.throws(() => assertTenantIsolation('t1', 't2')); + }); +}); + +describe('HttpHybxClient', () => { + it('refuses production environment', () => { + assert.throws( + () => + new HttpHybxClient({ + testMode: false, + config: { + environment: 'production', + baseUrl: 'https://hybx.example.invalid', + clientId: 'p', + clientSecret: 'p', + apiKey: 'p', + webhookSecret: 'p', + requestTimeoutMs: 1000, + maxRetries: 1, + }, + }), + /refuses production/ + ); + }); + + it('parses webhook payload shape', () => { + const client = new HttpHybxClient({ + testMode: true, + config: { + environment: 'sandbox', + baseUrl: 'https://hybx-sandbox.example.invalid', + clientId: 'placeholder', + clientSecret: 'placeholder', + apiKey: 'placeholder', + webhookSecret: 'placeholder', + requestTimeoutMs: 1000, + maxRetries: 1, + }, + }); + const parsed = client.parseWebhookPayload( + JSON.stringify({ eventType: 'settlement.completed', eventId: 'evt-1', data: { amount: '100' } }) + ); + assert.equal(parsed.eventType, 'settlement.completed'); + assert.equal(parsed.eventId, 'evt-1'); + assert.equal((parsed.data as { amount: string }).amount, '100'); + }); +}); + +describe('MockReconciliationEngine', () => { + it('detects missing fineract refs', async () => { + const engine = new MockReconciliationEngine(); + const snap = await engine.reconcileTripleState({ + tenantId: 't1', + entityId: 'e1', + hybxRefs: ['ref-1'], + fineractRefs: [], + chainRefs: ['ref-1'], + }); + assert.equal(snap.status, 'breaks_found'); + assert.ok(snap.breaks.length >= 1); + }); +}); + +describe('CircuitBreaker', () => { + it('opens after threshold failures', async () => { + const cb = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 60_000 }); + const fail = () => cb.execute(async () => { throw new Error('fail'); }); + await assert.rejects(fail); + await assert.rejects(fail); + assert.equal(cb.getState(), 'open'); + }); +}); + +describe('webhook verification', () => { + it('verifies HMAC signature', () => { + const secret = 'test-webhook-secret-dummy'; + const payload = '{"event":"payment.settled"}'; + const sig = computeWebhookSignature(secret, payload); + assert.equal( + verifyWebhookSignature({ secret, payload, signature: sig }), + true + ); + assert.equal( + verifyWebhookSignature({ secret, payload, signature: 'bad' }), + false + ); + }); + + it('verifies sha256= prefixed OMNL-style signatures', () => { + const secret = 'omnl-webhook-secret'; + const payload = '{"event":"ReserveCommitted"}'; + const sig = `sha256=${computeWebhookSignature(secret, payload)}`; + assert.equal(verifyWebhookSignature({ secret, payload, signature: sig }), true); + }); +}); diff --git a/packages/integration-foundation/src/iso20022/CanonicalFinancialEvent.ts b/packages/integration-foundation/src/iso20022/CanonicalFinancialEvent.ts new file mode 100644 index 0000000..025ba66 --- /dev/null +++ b/packages/integration-foundation/src/iso20022/CanonicalFinancialEvent.ts @@ -0,0 +1,97 @@ +/** Extended canonical financial event aligned to ISO 20022 concepts (not full XML). */ + +export type PartyAccount = { + id: string; + name?: string; + iban?: string; + bic?: string; + address?: string; +}; + +export type FinancialParty = { + id: string; + name?: string; + lei?: string; + jurisdiction?: string; + account?: PartyAccount; +}; + +export type ComplianceDecisionRef = 'allow' | 'block' | 'review' | 'hold' | 'reject' | 'pending'; + +export type CanonicalFinancialEvent = { + schema_version: string; + message_id: string; + end_to_end_id: string; + transaction_id?: string; + uetr?: string; + debtor: FinancialParty; + creditor: FinancialParty; + debtor_agent?: FinancialParty; + creditor_agent?: FinancialParty; + debtor_account?: PartyAccount; + creditor_account?: PartyAccount; + amount: string; + currency: string; + settlement_date?: string; + purpose_code?: string; + remittance_information?: string; + status?: string; + reason_code?: string; + source_system: string; + target_system: string; + entity_id: string; + tenant_id: string; + risk_score?: number; + compliance_decision?: ComplianceDecisionRef; + audit_event_id?: string; + raw_reference?: string; +}; + +export type CanonicalValidationResult = { + valid: boolean; + errors: string[]; +}; + +const REQUIRED_FIELDS: (keyof CanonicalFinancialEvent)[] = [ + 'schema_version', + 'message_id', + 'end_to_end_id', + 'debtor', + 'creditor', + 'amount', + 'currency', + 'source_system', + 'target_system', + 'entity_id', + 'tenant_id', +]; + +export function validateCanonicalFinancialEvent( + event: Partial +): CanonicalValidationResult { + const errors: string[] = []; + + for (const field of REQUIRED_FIELDS) { + const value = event[field]; + if (value === undefined || value === null || value === '') { + errors.push(`Missing required field: ${field}`); + } + } + + if (event.debtor && !event.debtor.id) { + errors.push('debtor.id is required'); + } + if (event.creditor && !event.creditor.id) { + errors.push('creditor.id is required'); + } + + if (event.amount !== undefined && !/^\d+(\.\d+)?$/.test(event.amount)) { + errors.push('amount must be a numeric string'); + } + + if (event.currency !== undefined && !/^[A-Z]{3}$/.test(event.currency)) { + errors.push('currency must be a 3-letter ISO 4217 code'); + } + + return { valid: errors.length === 0, errors }; +} diff --git a/packages/integration-foundation/src/iso20022/mappings.md b/packages/integration-foundation/src/iso20022/mappings.md new file mode 100644 index 0000000..6b42393 --- /dev/null +++ b/packages/integration-foundation/src/iso20022/mappings.md @@ -0,0 +1,18 @@ +# ISO 20022 message family mappings (canonical layer) + +Lightweight mapping from ISO 20022 families to `@dbis/integration-foundation` `CanonicalFinancialEvent`. +Full XML generation and XSD validation are **future work**. + +| ISO family | Direction | Canonical fields populated | +|------------|-----------|---------------------------| +| pain.001 | Outbound initiation | message_id, end_to_end_id, debtor, creditor, amount, currency, purpose_code, remittance_information | +| pain.002 | Status report | message_id, status, reason_code, end_to_end_id | +| pacs.008 | FI-to-FI credit transfer | message_id, end_to_end_id, uetr, debtor/creditor agents & accounts, amount, currency, settlement_date | +| pacs.009 | FI-to-FI cover payment | message_id, end_to_end_id, uetr, debtor_agent, creditor_agent, amount, currency | +| pacs.002 | Payment status | message_id, status, reason_code, end_to_end_id, uetr | +| pacs.004 | Payment return | message_id, end_to_end_id, reason_code, amount, currency | +| camt.052 | Intraday report | message_id, settlement_date, account balances (raw_reference) | +| camt.053 | Statement | message_id, settlement_date, raw_reference | +| camt.054 | Debit/credit notification | message_id, end_to_end_id, amount, currency, remittance_information | + +**Unsupported in v0.1:** Full party address decomposition, charge bearer, structured remittance arrays, official schema validation. diff --git a/packages/integration-foundation/src/observability/correlation.ts b/packages/integration-foundation/src/observability/correlation.ts new file mode 100644 index 0000000..e8e8d78 --- /dev/null +++ b/packages/integration-foundation/src/observability/correlation.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'crypto'; + +export const CORRELATION_ID_HEADER = 'x-correlation-id'; +export const REQUEST_ID_HEADER = 'x-request-id'; + +export function generateCorrelationId(): string { + return randomUUID(); +} + +export function generateRequestId(): string { + return `req-${randomUUID()}`; +} + +export type CorrelationContext = { + correlationId: string; + requestId: string; + tenantId?: string; + entityId?: string; +}; + +function orGenerated(value: string | undefined, fallback: () => string): string { + const v = value?.trim(); + return v ? v : fallback(); +} + +export function createCorrelationContext( + partial?: Partial +): CorrelationContext { + return { + correlationId: orGenerated(partial?.correlationId, generateCorrelationId), + requestId: orGenerated(partial?.requestId, generateRequestId), + tenantId: partial?.tenantId, + entityId: partial?.entityId, + }; +} + +export function correlationHeaders(ctx: CorrelationContext): Record { + const headers: Record = { + [CORRELATION_ID_HEADER]: ctx.correlationId, + [REQUEST_ID_HEADER]: ctx.requestId, + }; + if (ctx.tenantId) headers['x-tenant-id'] = ctx.tenantId; + if (ctx.entityId) headers['x-entity-id'] = ctx.entityId; + return headers; +} diff --git a/packages/integration-foundation/src/reconciliation/ReconciliationStatus.ts b/packages/integration-foundation/src/reconciliation/ReconciliationStatus.ts new file mode 100644 index 0000000..8f60afb --- /dev/null +++ b/packages/integration-foundation/src/reconciliation/ReconciliationStatus.ts @@ -0,0 +1,99 @@ +export type ReconciliationBreakType = + | 'amount_mismatch' + | 'missing_event' + | 'duplicate_event' + | 'status_mismatch' + | 'ledger_drift' + | 'chain_drift'; + +export type ReconciliationBreak = { + breakId: string; + type: ReconciliationBreakType; + source: 'hybx' | 'fineract' | 'chain' | 'internal'; + referenceId: string; + expected?: string; + actual?: string; + detectedAt: string; +}; + +export type ReconciliationSnapshot = { + snapshotId: string; + tenantId: string; + entityId: string; + asOf: string; + hybxEventCount: number; + fineractPostingCount: number; + chainSettlementCount: number; + breaks: ReconciliationBreak[]; + status: 'matched' | 'breaks_found' | 'pending'; +}; + +export interface ReconciliationEngine { + reconcileTripleState(input: { + tenantId: string; + entityId: string; + hybxRefs: string[]; + fineractRefs: string[]; + chainRefs: string[]; + }): Promise; +} + +export class MockReconciliationEngine implements ReconciliationEngine { + async reconcileTripleState(input: { + tenantId: string; + entityId: string; + hybxRefs: string[]; + fineractRefs: string[]; + chainRefs: string[]; + }): Promise { + const breaks: ReconciliationBreak[] = []; + const hybxSet = new Set(input.hybxRefs); + const fineractSet = new Set(input.fineractRefs); + const chainSet = new Set(input.chainRefs); + + for (const ref of input.hybxRefs) { + if (!fineractSet.has(ref)) { + breaks.push({ + breakId: `brk-fineract-${ref}`, + type: 'missing_event', + source: 'fineract', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + if (!chainSet.has(ref)) { + breaks.push({ + breakId: `brk-chain-${ref}`, + type: 'missing_event', + source: 'chain', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + } + + for (const ref of input.fineractRefs) { + if (!hybxSet.has(ref)) { + breaks.push({ + breakId: `brk-hybx-${ref}`, + type: 'missing_event', + source: 'hybx', + referenceId: ref, + detectedAt: new Date().toISOString(), + }); + } + } + + return { + snapshotId: `recon-${Date.now()}`, + tenantId: input.tenantId, + entityId: input.entityId, + asOf: new Date().toISOString(), + hybxEventCount: input.hybxRefs.length, + fineractPostingCount: input.fineractRefs.length, + chainSettlementCount: input.chainRefs.length, + breaks, + status: breaks.length === 0 ? 'matched' : 'breaks_found', + }; + } +} diff --git a/packages/integration-foundation/src/resilience/circuitBreaker.ts b/packages/integration-foundation/src/resilience/circuitBreaker.ts new file mode 100644 index 0000000..6c46e7f --- /dev/null +++ b/packages/integration-foundation/src/resilience/circuitBreaker.ts @@ -0,0 +1,55 @@ +export type CircuitState = 'closed' | 'open' | 'half_open'; + +export type CircuitBreakerOptions = { + failureThreshold: number; + resetTimeoutMs: number; +}; + +export class CircuitBreaker { + private failures = 0; + private state: CircuitState = 'closed'; + private openedAt = 0; + private readonly options: CircuitBreakerOptions; + + constructor(options?: Partial) { + this.options = { + failureThreshold: options?.failureThreshold ?? 5, + resetTimeoutMs: options?.resetTimeoutMs ?? 30_000, + }; + } + + getState(): CircuitState { + if (this.state === 'open' && Date.now() - this.openedAt >= this.options.resetTimeoutMs) { + this.state = 'half_open'; + } + return this.state; + } + + async execute(fn: () => Promise): Promise { + const state = this.getState(); + if (state === 'open') { + throw new Error('Circuit breaker open'); + } + try { + const result = await fn(); + this.onSuccess(); + return result; + } catch (e) { + this.onFailure(); + throw e; + } + } + + private onSuccess(): void { + this.failures = 0; + this.state = 'closed'; + } + + private onFailure(): void { + this.failures += 1; + if (this.failures >= this.options.failureThreshold) { + this.state = 'open'; + this.openedAt = Date.now(); + } + } +} diff --git a/packages/integration-foundation/src/resilience/idempotency.ts b/packages/integration-foundation/src/resilience/idempotency.ts new file mode 100644 index 0000000..4f87a5b --- /dev/null +++ b/packages/integration-foundation/src/resilience/idempotency.ts @@ -0,0 +1,34 @@ +import { createHash } from 'crypto'; + +export type IdempotencyInput = { + tenantId: string; + entityId: string; + operation: string; + resourceId?: string; + payload?: Record; +}; + +export function deriveIdempotencyKey(input: IdempotencyInput): string { + const canonical = JSON.stringify({ + tenantId: input.tenantId, + entityId: input.entityId, + operation: input.operation, + resourceId: input.resourceId ?? '', + payload: input.payload ?? {}, + }); + return createHash('sha256').update(canonical).digest('hex'); +} + +export class IdempotencyStore { + private readonly keys = new Set(); + + has(key: string): boolean { + return this.keys.has(key); + } + + record(key: string): boolean { + if (this.keys.has(key)) return false; + this.keys.add(key); + return true; + } +} diff --git a/packages/integration-foundation/src/webhooks/verifySignature.ts b/packages/integration-foundation/src/webhooks/verifySignature.ts new file mode 100644 index 0000000..f48caa7 --- /dev/null +++ b/packages/integration-foundation/src/webhooks/verifySignature.ts @@ -0,0 +1,37 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +export type WebhookVerifyOptions = { + secret: string; + payload: string; + signature: string; + algorithm?: 'sha256' | 'sha512'; + encoding?: 'hex' | 'base64'; +}; + +export function computeWebhookSignature( + secret: string, + payload: string, + algorithm: 'sha256' | 'sha512' = 'sha256', + encoding: 'hex' | 'base64' = 'hex' +): string { + return createHmac(algorithm, secret).update(payload, 'utf8').digest(encoding); +} + +export function verifyWebhookSignature(options: WebhookVerifyOptions): boolean { + const { secret, payload, signature, algorithm = 'sha256', encoding = 'hex' } = options; + if (!secret || !signature) return false; + + const expected = computeWebhookSignature(secret, payload, algorithm, encoding); + const provided = signature.startsWith('sha256=') || signature.startsWith('sha512=') + ? signature.split('=')[1] ?? '' + : signature; + + try { + const a = Buffer.from(expected, encoding); + const b = Buffer.from(provided, encoding); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/packages/integration-foundation/tsconfig.json b/packages/integration-foundation/tsconfig.json new file mode 100644 index 0000000..3cacd25 --- /dev/null +++ b/packages/integration-foundation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/scripts/configure-network-advanced.py b/scripts/configure-network-advanced.py index 560e6b7..3fcf838 100755 --- a/scripts/configure-network-advanced.py +++ b/scripts/configure-network-advanced.py @@ -16,7 +16,18 @@ import base64 # Import base configuration tool sys.path.insert(0, str(Path(__file__).parent)) -from configure_network import NetworkConfigurator, Colors, print_header, print_success, print_error, print_warning, print_info +from configure_network import ( + NetworkConfigurator, + Colors, + print_header, + print_success, + print_error, + print_warning, + print_info, + input_yes_no, + input_int, + input_with_default, +) class AdvancedNetworkConfigurator(NetworkConfigurator): """Extended network configurator with advanced options""" diff --git a/scripts/configure-network-validation.py b/scripts/configure-network-validation.py index 64c8dfa..e2cc32f 100644 --- a/scripts/configure-network-validation.py +++ b/scripts/configure-network-validation.py @@ -135,7 +135,10 @@ class ConfigurationValidator: if not self._validate_cidr(subnet_cidr): self.errors.append(f"Invalid {subnet_name} subnet: {subnet_cidr}. Must be valid CIDR notation") else: - subnet_network, subnet_mask = self._parse_cidr(subnet_cidr) + parsed_subnet = self._parse_cidr(subnet_cidr) + if parsed_subnet is None: + continue + subnet_network, subnet_mask = parsed_subnet if subnet_network and not self._is_subnet_of(subnet_network, subnet_mask, vnet_network, vnet_mask): self.errors.append(f"{subnet_name} subnet {subnet_cidr} is not within VNet {vnet_address}") if subnet_mask < vnet_mask: diff --git a/scripts/configure-network.py b/scripts/configure-network.py index 2d29537..b0d13cb 100755 --- a/scripts/configure-network.py +++ b/scripts/configure-network.py @@ -80,7 +80,7 @@ def input_with_default(prompt: str, default: str = "", validate_func=None) -> st continue return value -def input_int(prompt: str, default: int = 0, min_val: int = None, max_val: int = None) -> int: +def input_int(prompt: str, default: int = 0, min_val: Optional[int] = None, max_val: Optional[int] = None) -> int: """Get integer input with validation""" while True: try: @@ -161,7 +161,7 @@ def validate_chain_id(chain_id: str) -> bool: class NetworkConfigurator: def __init__(self, project_root: Path): self.project_root = project_root - self.config = {} + self.config: Dict[str, Any] = {} self.backup_dir = project_root / ".config-backup" def backup_existing_files(self): @@ -252,12 +252,14 @@ class NetworkConfigurator: # Subnets print_info("\nSubnet Configuration") - self.config['network']['subnets'] = { + network_cfg: Dict[str, Any] = dict(self.config['network']) + network_cfg['subnets'] = { 'validators': input_with_default("Validators subnet (CIDR)", "10.0.1.0/24", validate_cidr), 'sentries': input_with_default("Sentries subnet (CIDR)", "10.0.2.0/24", validate_cidr), 'rpc': input_with_default("RPC subnet (CIDR)", "10.0.3.0/24", validate_cidr), 'aks': input_with_default("AKS subnet (CIDR)", "10.0.4.0/24", validate_cidr), } + self.config['network'] = network_cfg # Node counts print_info("\nNode Configuration") diff --git a/scripts/configure_network.py b/scripts/configure_network.py new file mode 100644 index 0000000..f738a1b --- /dev/null +++ b/scripts/configure_network.py @@ -0,0 +1,48 @@ +"""Import shim for configure-network.py (hyphenated filename).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any, Callable + +_impl_path = Path(__file__).with_name("configure-network.py") +_spec = importlib.util.spec_from_file_location("_configure_network_impl", _impl_path) +if _spec is None or _spec.loader is None: + raise ImportError(f"Cannot load {_impl_path}") +_impl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_impl) + +NetworkConfigurator = _impl.NetworkConfigurator +Colors = _impl.Colors +print_header: Callable[..., None] = _impl.print_header +print_success: Callable[..., None] = _impl.print_success +print_error: Callable[..., None] = _impl.print_error +print_warning: Callable[..., None] = _impl.print_warning +print_info: Callable[..., None] = _impl.print_info +input_with_default: Callable[..., str] = _impl.input_with_default +input_int: Callable[..., int] = _impl.input_int +input_yes_no: Callable[..., bool] = _impl.input_yes_no +input_hex: Callable[..., str] = _impl.input_hex +validate_ip: Callable[[str], bool] = _impl.validate_ip +validate_cidr: Callable[[str], bool] = _impl.validate_cidr +validate_port: Callable[[str], bool] = _impl.validate_port +validate_chain_id: Callable[[str], bool] = _impl.validate_chain_id + +__all__ = [ + "NetworkConfigurator", + "Colors", + "print_header", + "print_success", + "print_error", + "print_warning", + "print_info", + "input_with_default", + "input_int", + "input_yes_no", + "input_hex", + "validate_ip", + "validate_cidr", + "validate_port", + "validate_chain_id", +] diff --git a/scripts/configure_network_validation.py b/scripts/configure_network_validation.py new file mode 100644 index 0000000..cd1bb10 --- /dev/null +++ b/scripts/configure_network_validation.py @@ -0,0 +1,17 @@ +"""Import shim for configure-network-validation.py (hyphenated filename).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +_impl_path = Path(__file__).with_name("configure-network-validation.py") +_spec = importlib.util.spec_from_file_location("_configure_network_validation_impl", _impl_path) +if _spec is None or _spec.loader is None: + raise ImportError(f"Cannot load {_impl_path}") +_impl = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_impl) + +ConfigurationValidator = _impl.ConfigurationValidator +ValidationError = _impl.ValidationError + +__all__ = ["ConfigurationValidator", "ValidationError"] diff --git a/scripts/forge/report-contract-reachability.py b/scripts/forge/report-contract-reachability.py index 7e5e77b..3a8e6d7 100755 --- a/scripts/forge/report-contract-reachability.py +++ b/scripts/forge/report-contract-reachability.py @@ -9,6 +9,7 @@ import re import sys from collections import Counter from pathlib import Path +from typing import Any, cast EXCLUDED_PARTS = {"artifacts", "broadcast", "cache", "lib", "node_modules", "out"} @@ -96,8 +97,10 @@ def create_report(repo_root: Path) -> dict[str, object]: } -def print_text(report: dict[str, object]) -> None: - summary = report["summary"] +def print_text(report: dict[str, Any]) -> None: + summary = cast(dict[str, int], report["summary"]) + unreachable_by_bucket = cast(dict[str, int], report["unreachableByBucket"]) + unreachable_contracts = cast(list[str], report["unreachableContracts"]) print(f"repo root: {report['repoRoot']}") print(f"contracts total: {summary['contractsTotal']}") print(f"tests/scripts roots total: {summary['rootsTotal']}") @@ -106,11 +109,11 @@ def print_text(report: dict[str, object]) -> None: print(f"contracts with no local inbound refs: {summary['contractsWithNoLocalInboundRefs']}") print() print("Unreachable by top-level bucket:") - for bucket, count in report["unreachableByBucket"].items(): + for bucket, count in unreachable_by_bucket.items(): print(f" {bucket}: {count}") print() print("Archive candidates (unreachable from current tests/scripts):") - for contract in report["unreachableContracts"]: + for contract in unreachable_contracts: print(f" {contract}") print() print("Note: unreachable does not prove safe deletion; it only means this repo's current Solidity tests/scripts do not import the file.") diff --git a/services/bridge-monitor/bridge-monitor.py b/services/bridge-monitor/bridge-monitor.py index 6d5fb9d..35c237e 100755 --- a/services/bridge-monitor/bridge-monitor.py +++ b/services/bridge-monitor/bridge-monitor.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) class BridgeMonitor: """Main bridge monitoring service""" - def __init__(self, config_path: str = None): + def __init__(self, config_path: Optional[str] = None): """Initialize bridge monitor with configuration""" self.config = self._load_config(config_path) self.chain138_w3 = self._init_web3(self.config['chain138_rpc']) diff --git a/services/financial-tokenization/parsers/iso20022_parser.py b/services/financial-tokenization/parsers/iso20022_parser.py index 28858c7..b21cf6f 100644 --- a/services/financial-tokenization/parsers/iso20022_parser.py +++ b/services/financial-tokenization/parsers/iso20022_parser.py @@ -191,7 +191,9 @@ class ISO20022Parser: """Get text from XML element""" try: element = root.find(xpath, self.NAMESPACES) - return element.text if element is not None else default + if element is None: + return default + return element.text if element.text is not None else default except Exception: return default diff --git a/services/oracle-publisher/oracle_publisher.py b/services/oracle-publisher/oracle_publisher.py index 95db987..994b3bb 100644 --- a/services/oracle-publisher/oracle_publisher.py +++ b/services/oracle-publisher/oracle_publisher.py @@ -10,7 +10,7 @@ import logging import time from typing import List, Optional from decimal import Decimal -from dataclasses import dataclass +from dataclasses import dataclass, field from web3 import Web3 from web3.middleware import geth_poa_middleware from eth_account import Account @@ -57,7 +57,7 @@ class OracleConfig: private_key: str heartbeat: int = 60 # seconds deviation_threshold: float = 0.5 # percentage - data_sources: List[DataSource] = None + data_sources: List[DataSource] = field(default_factory=list) gas_price: Optional[int] = None gas_limit: int = 100000 max_priority_fee: Optional[int] = None diff --git a/services/oracle-publisher/oracle_publisher_improved.py b/services/oracle-publisher/oracle_publisher_improved.py index 7f21e52..4945c47 100644 --- a/services/oracle-publisher/oracle_publisher_improved.py +++ b/services/oracle-publisher/oracle_publisher_improved.py @@ -11,7 +11,7 @@ import logging import time from typing import List, Optional from decimal import Decimal -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from web3 import Web3 from web3.middleware import geth_poa_middleware @@ -63,7 +63,7 @@ class OracleConfig: private_key: str heartbeat: int = 60 # seconds deviation_threshold: float = 0.5 # percentage - data_sources: List[DataSource] = None + data_sources: List[DataSource] = field(default_factory=list) gas_price: Optional[int] = None gas_limit: int = 100000 max_priority_fee: Optional[int] = None @@ -81,13 +81,13 @@ class CircuitBreaker: self.failure_threshold = failure_threshold self.timeout = timeout self.failure_count = 0 - self.last_failure_time = None + self.last_failure_time: Optional[float] = None self.state = CircuitBreakerState.CLOSED def call(self, func, *args, **kwargs): """Execute function with circuit breaker protection""" if self.state == CircuitBreakerState.OPEN: - if time.time() - self.last_failure_time > self.timeout: + if self.last_failure_time is not None and time.time() - self.last_failure_time > self.timeout: self.state = CircuitBreakerState.HALF_OPEN logger.info("Circuit breaker: Moving to HALF_OPEN state") else: diff --git a/services/relay/start-relay.sh b/services/relay/start-relay.sh index 3f6474a..8f41127 100755 --- a/services/relay/start-relay.sh +++ b/services/relay/start-relay.sh @@ -4,6 +4,16 @@ cd "$(dirname "$0")" PROJECT_ROOT="$(cd ../.. && pwd)" PROFILE="${1:-}" + +# Production relays run on Proxmox (/opt/smom-dbis-138), not operator workstation checkouts. +if [[ -f "$PROJECT_ROOT/../../scripts/lib/chainlink-production-placement.sh" ]]; then + # shellcheck source=../../scripts/lib/chainlink-production-placement.sh + source "$PROJECT_ROOT/../../scripts/lib/chainlink-production-placement.sh" + require_ccip_relay_on_proxmox || exit 1 +elif [[ "$(pwd)" == *"/projects/"*"/services/relay"* ]] && [[ "${CCIP_RELAY_ALLOW_LOCAL:-0}" != "1" ]]; then + echo "ERROR: CCIP relay must run on Proxmox r630-01 (/opt/smom-dbis-138/services/relay). Set CCIP_RELAY_ALLOW_LOCAL=1 for dev." >&2 + exit 1 +fi SKIP_ENV_LOCAL="${RELAY_SKIP_ENV_LOCAL:-0}" declare -A ORIGINAL_ENV_VARS=() diff --git a/services/swift-listener/dist/adapters/inboundAdapters.d.ts b/services/swift-listener/dist/adapters/inboundAdapters.d.ts new file mode 100644 index 0000000..f1341a9 --- /dev/null +++ b/services/swift-listener/dist/adapters/inboundAdapters.d.ts @@ -0,0 +1,18 @@ +import type { InboundMessage, InboundFormat } from '../types'; +export declare function scanDirectoryInbox(inboxDir: string, sourcePrefix: string, defaultFormat?: InboundFormat): InboundMessage[]; +export declare function archiveInboxFile(filePath: string, archiveDir: string): void; +export declare function scanFileInbox(inboxDir: string): InboundMessage[]; +export declare function pollP2pRailTransactions(options: { + baseUrl: string; + bearerToken?: string; + apiKey?: string; + path?: string; + sinceId?: string; +}): Promise; +export declare function pollFinGatewayMessages(options: { + baseUrl: string; + bearerToken?: string; + path?: string; + sinceId?: string; +}): Promise; +//# sourceMappingURL=inboundAdapters.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/inboundAdapters.d.ts.map b/services/swift-listener/dist/adapters/inboundAdapters.d.ts.map new file mode 100644 index 0000000..ae27c6b --- /dev/null +++ b/services/swift-listener/dist/adapters/inboundAdapters.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"inboundAdapters.d.ts","sourceRoot":"","sources":["../../src/adapters/inboundAdapters.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAK9D,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,aAAa,GAC5B,cAAc,EAAE,CA2BlB;AAED,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,IAAI,CAIN;AAED,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,EAAE,CAEhE;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAmC5B;AAED,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAwC5B"} \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/inboundAdapters.js b/services/swift-listener/dist/adapters/inboundAdapters.js new file mode 100644 index 0000000..992afe8 --- /dev/null +++ b/services/swift-listener/dist/adapters/inboundAdapters.js @@ -0,0 +1,127 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.scanDirectoryInbox = scanDirectoryInbox; +exports.archiveInboxFile = archiveInboxFile; +exports.scanFileInbox = scanFileInbox; +exports.pollP2pRailTransactions = pollP2pRailTransactions; +exports.pollFinGatewayMessages = pollFinGatewayMessages; +const fs_1 = require("fs"); +const path_1 = require("path"); +const inboundParser_1 = require("../parsers/inboundParser"); +const INBOX_EXTENSIONS = /\.(fin|swift|xml|txt|json|as4|mx)$/i; +function scanDirectoryInbox(inboxDir, sourcePrefix, defaultFormat) { + const messages = []; + let entries = []; + try { + entries = (0, fs_1.readdirSync)(inboxDir); + } + catch { + return messages; + } + for (const name of entries.sort()) { + const full = (0, path_1.join)(inboxDir, name); + if (!(0, fs_1.statSync)(full).isFile()) + continue; + if (name.startsWith('.')) + continue; + if (!INBOX_EXTENSIONS.test(name)) + continue; + const payload = (0, fs_1.readFileSync)(full, 'utf8'); + const lower = name.toLowerCase(); + let format = defaultFormat ?? (0, inboundParser_1.detectInboundFormat)(payload); + if (lower.endsWith('.xml') || lower.endsWith('.mx') || lower.endsWith('.as4')) + format = 'iso20022'; + if (lower.endsWith('.json')) + format = 'json_broadcast'; + messages.push({ + source: `${sourcePrefix}:${full}`, + format, + payload, + metadata: { fileName: name, filePath: full }, + }); + } + return messages; +} +function archiveInboxFile(filePath, archiveDir) { + (0, fs_1.mkdirSync)(archiveDir, { recursive: true }); + const base = filePath.split('/').pop() ?? 'message'; + (0, fs_1.renameSync)(filePath, (0, path_1.join)(archiveDir, `${Date.now()}-${base}`)); +} +function scanFileInbox(inboxDir) { + return scanDirectoryInbox(inboxDir, 'file'); +} +async function pollP2pRailTransactions(options) { + const url = `${options.baseUrl.replace(/\/$/, '')}${options.path ?? '/api/transactions'}`; + const headers = { Accept: 'application/json' }; + if (options.bearerToken) + headers.Authorization = `Bearer ${options.bearerToken}`; + if (options.apiKey) + headers['X-API-Key'] = options.apiKey; + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`P2P rail poll failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()); + const rows = Array.isArray(body) ? body : body?.data ?? []; + const out = []; + for (const row of rows) { + const obj = row; + const id = String(obj.id ?? obj.transactionId ?? obj.reference ?? ''); + if (options.sinceId && id && id <= options.sinceId) + continue; + const payload = typeof obj.payload === 'string' + ? obj.payload + : typeof obj.swiftMessage === 'string' + ? obj.swiftMessage + : typeof obj.message === 'string' + ? obj.message + : JSON.stringify(obj); + out.push({ + source: `p2p:${id || 'unknown'}`, + format: (0, inboundParser_1.detectInboundFormat)(payload), + payload, + metadata: obj, + }); + } + return out; +} +async function pollFinGatewayMessages(options) { + const url = `${options.baseUrl.replace(/\/$/, '')}${options.path ?? '/api/messages'}`; + const headers = { Accept: 'application/json' }; + if (options.bearerToken) + headers.Authorization = `Bearer ${options.bearerToken}`; + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`FIN gateway poll failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()); + const rows = Array.isArray(body) + ? body + : body?.messages ?? + body?.data ?? + []; + const out = []; + for (const row of rows) { + const obj = row; + const id = String(obj.id ?? obj.messageId ?? obj.reference ?? ''); + if (options.sinceId && id && id <= options.sinceId) + continue; + const payload = typeof obj.finMessage === 'string' + ? obj.finMessage + : typeof obj.swiftMessage === 'string' + ? obj.swiftMessage + : typeof obj.payload === 'string' + ? obj.payload + : typeof obj.body === 'string' + ? obj.body + : JSON.stringify(obj); + out.push({ + source: `fin:${id || 'unknown'}`, + format: (0, inboundParser_1.detectInboundFormat)(payload), + payload, + metadata: obj, + }); + } + return out; +} +//# sourceMappingURL=inboundAdapters.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/inboundAdapters.js.map b/services/swift-listener/dist/adapters/inboundAdapters.js.map new file mode 100644 index 0000000..c57dd52 --- /dev/null +++ b/services/swift-listener/dist/adapters/inboundAdapters.js.map @@ -0,0 +1 @@ +{"version":3,"file":"inboundAdapters.js","sourceRoot":"","sources":["../../src/adapters/inboundAdapters.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts new file mode 100644 index 0000000..278d050 --- /dev/null +++ b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts @@ -0,0 +1,14 @@ +import { type Server } from 'net'; +import type { InboundMessage } from '../types'; +/** Extract FIN messages from a TCP buffer (brace blocks or $...$ framed). */ +export declare function extractFinMessages(buffer: string): { + messages: string[]; + remainder: string; +}; +export type SwiftFinTcpServerOptions = { + host: string; + port: number; + onMessage: (msg: InboundMessage) => void; +}; +export declare function startSwiftFinTcpServer(options: SwiftFinTcpServerOptions): Server; +//# sourceMappingURL=swiftFinTcpAdapter.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts.map b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts.map new file mode 100644 index 0000000..ec6554f --- /dev/null +++ b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"swiftFinTcpAdapter.d.ts","sourceRoot":"","sources":["../../src/adapters/swiftFinTcpAdapter.ts"],"names":[],"mappings":"AAAA,OAAY,EAAE,KAAK,MAAM,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG/C,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CA4B5F;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;CAC1C,CAAC;AAEF,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CA8BhF"} \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js new file mode 100644 index 0000000..af525e2 --- /dev/null +++ b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js @@ -0,0 +1,64 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.extractFinMessages = extractFinMessages; +exports.startSwiftFinTcpServer = startSwiftFinTcpServer; +const net_1 = __importDefault(require("net")); +const inboundParser_1 = require("../parsers/inboundParser"); +/** Extract FIN messages from a TCP buffer (brace blocks or $...$ framed). */ +function extractFinMessages(buffer) { + const messages = []; + let rest = buffer; + while (rest.length > 0) { + const dollarStart = rest.indexOf('$'); + const braceStart = rest.indexOf('{1:'); + if (braceStart >= 0 && (dollarStart < 0 || braceStart < dollarStart)) { + const end = rest.indexOf('-}', braceStart); + if (end < 0) + break; + messages.push(rest.slice(braceStart, end + 2)); + rest = rest.slice(end + 2); + continue; + } + if (dollarStart >= 0) { + const end = rest.indexOf('$', dollarStart + 1); + if (end < 0) + break; + messages.push(rest.slice(dollarStart + 1, end)); + rest = rest.slice(end + 1); + continue; + } + break; + } + return { messages, remainder: rest }; +} +function startSwiftFinTcpServer(options) { + const buffers = new Map(); + const server = net_1.default.createServer((socket) => { + const key = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`; + buffers.set(key, ''); + socket.on('data', (chunk) => { + const prev = buffers.get(key) ?? ''; + const combined = prev + chunk.toString('utf8'); + const { messages, remainder } = extractFinMessages(combined); + buffers.set(key, remainder); + for (const payload of messages) { + if (!payload.trim()) + continue; + options.onMessage({ + source: `tcp:${key}:${Date.now()}`, + format: (0, inboundParser_1.detectInboundFormat)(payload), + payload, + metadata: { remote: key }, + }); + } + }); + socket.on('close', () => buffers.delete(key)); + socket.on('error', () => buffers.delete(key)); + }); + server.listen(options.port, options.host); + return server; +} +//# sourceMappingURL=swiftFinTcpAdapter.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js.map b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js.map new file mode 100644 index 0000000..7cd287b --- /dev/null +++ b/services/swift-listener/dist/adapters/swiftFinTcpAdapter.js.map @@ -0,0 +1 @@ +{"version":3,"file":"swiftFinTcpAdapter.js","sourceRoot":"","sources":["../../src/adapters/swiftFinTcpAdapter.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/webhookServer.d.ts b/services/swift-listener/dist/adapters/webhookServer.d.ts new file mode 100644 index 0000000..f2e6e9b --- /dev/null +++ b/services/swift-listener/dist/adapters/webhookServer.d.ts @@ -0,0 +1,13 @@ +import { type Server } from 'http'; +import type { InboundMessage } from '../types'; +export type WebhookServerOptions = { + port: number; + path: string; + healthPath?: string; + authBearer?: string; + webhookSecret?: string; + webhookSignatureHeader?: string; + onMessage: (msg: InboundMessage) => Promise; +}; +export declare function createWebhookServer(options: WebhookServerOptions): Server; +//# sourceMappingURL=webhookServer.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/webhookServer.d.ts.map b/services/swift-listener/dist/adapters/webhookServer.d.ts.map new file mode 100644 index 0000000..2b2f4a1 --- /dev/null +++ b/services/swift-listener/dist/adapters/webhookServer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"webhookServer.d.ts","sourceRoot":"","sources":["../../src/adapters/webhookServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,MAAM,EAA6C,MAAM,MAAM,CAAC;AAU5F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG/C,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,SAAS,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD,CAAC;AAyBF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,CA4FzE"} \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/webhookServer.js b/services/swift-listener/dist/adapters/webhookServer.js new file mode 100644 index 0000000..5a2f0a1 --- /dev/null +++ b/services/swift-listener/dist/adapters/webhookServer.js @@ -0,0 +1,114 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createWebhookServer = createWebhookServer; +const http_1 = require("http"); +const url_1 = require("url"); +const integration_foundation_1 = require("@dbis/integration-foundation"); +const inboundParser_1 = require("../parsers/inboundParser"); +const idempotencyStore = new integration_foundation_1.IdempotencyStore(); +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} +function unauthorized(res, reason = 'unauthorized') { + res.statusCode = 401; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: reason })); +} +function conflict(res) { + res.statusCode = 409; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'duplicate idempotency key' })); +} +function createWebhookServer(options) { + const healthPath = options.healthPath ?? '/health'; + const sigHeader = options.webhookSignatureHeader ?? 'x-hybx-signature'; + return (0, http_1.createServer)((req, res) => { + void (async () => { + try { + const url = new url_1.URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + if (req.method === 'GET' && url.pathname === healthPath) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'ok', service: 'swift-listener' })); + return; + } + if (url.pathname !== options.path) { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'not found' })); + return; + } + if (req.method !== 'POST') { + res.statusCode = 405; + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + const raw = await readBody(req); + if (options.webhookSecret) { + const sig = String(req.headers[sigHeader] ?? req.headers['x-webhook-signature'] ?? ''); + if (!(0, integration_foundation_1.verifyWebhookSignature)({ secret: options.webhookSecret, payload: raw, signature: sig })) { + unauthorized(res, 'invalid webhook signature'); + return; + } + } + else if (options.authBearer) { + const auth = String(req.headers.authorization ?? ''); + const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (token !== options.authBearer) { + unauthorized(res); + return; + } + } + const idempotencyHeader = String(req.headers['x-idempotency-key'] ?? ''); + if (idempotencyHeader) { + const key = (0, integration_foundation_1.deriveIdempotencyKey)({ + tenantId: 'swift-listener', + entityId: 'webhook', + operation: 'inbound', + resourceId: idempotencyHeader, + }); + if (!idempotencyStore.record(key)) { + conflict(res); + return; + } + } + const correlation = (0, integration_foundation_1.createCorrelationContext)({ + correlationId: String(req.headers[integration_foundation_1.CORRELATION_ID_HEADER] ?? ''), + requestId: String(req.headers[integration_foundation_1.REQUEST_ID_HEADER] ?? req.headers['x-request-id'] ?? ''), + }); + const contentType = String(req.headers['content-type'] ?? ''); + let format = (0, inboundParser_1.detectInboundFormat)(raw); + if (contentType.includes('xml')) + format = 'iso20022'; + if (contentType.includes('json') && format !== 'swift_fin') + format = 'json_broadcast'; + await options.onMessage({ + source: `webhook:${correlation.requestId}`, + format, + payload: raw, + metadata: { + contentType, + path: url.pathname, + correlationId: correlation.correlationId, + requestId: correlation.requestId, + }, + }); + res.statusCode = 202; + res.setHeader('Content-Type', 'application/json'); + res.setHeader(integration_foundation_1.CORRELATION_ID_HEADER, correlation.correlationId); + res.setHeader(integration_foundation_1.REQUEST_ID_HEADER, correlation.requestId); + res.end(JSON.stringify({ accepted: true, correlationId: correlation.correlationId })); + } + catch (e) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); + } + })(); + }).listen(options.port); +} +//# sourceMappingURL=webhookServer.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/adapters/webhookServer.js.map b/services/swift-listener/dist/adapters/webhookServer.js.map new file mode 100644 index 0000000..80f6fa6 --- /dev/null +++ b/services/swift-listener/dist/adapters/webhookServer.js.map @@ -0,0 +1 @@ +{"version":3,"file":"webhookServer.js","sourceRoot":"","sources":["../../src/adapters/webhookServer.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/canonical/mapToCanonical.d.ts b/services/swift-listener/dist/canonical/mapToCanonical.d.ts new file mode 100644 index 0000000..74146f6 --- /dev/null +++ b/services/swift-listener/dist/canonical/mapToCanonical.d.ts @@ -0,0 +1,4 @@ +import { type CanonicalPaymentMessage } from '@dbis/checkpoint-core'; +import type { ParsedInbound } from '../types'; +export declare function mapToCanonical(parsed: ParsedInbound, dedupeKey: string): CanonicalPaymentMessage | undefined; +//# sourceMappingURL=mapToCanonical.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/canonical/mapToCanonical.d.ts.map b/services/swift-listener/dist/canonical/mapToCanonical.d.ts.map new file mode 100644 index 0000000..c4db7f5 --- /dev/null +++ b/services/swift-listener/dist/canonical/mapToCanonical.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"mapToCanonical.d.ts","sourceRoot":"","sources":["../../src/canonical/mapToCanonical.ts"],"names":[],"mappings":"AACA,OAAO,EAA2B,KAAK,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAC9F,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AA8B9C,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAoD5G"} \ No newline at end of file diff --git a/services/swift-listener/dist/canonical/mapToCanonical.js b/services/swift-listener/dist/canonical/mapToCanonical.js new file mode 100644 index 0000000..4116010 --- /dev/null +++ b/services/swift-listener/dist/canonical/mapToCanonical.js @@ -0,0 +1,83 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.mapToCanonical = mapToCanonical; +const ethers_1 = require("ethers"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +function msgTypeCode(parsed) { + if (parsed.format === 'swift_fin') { + if (parsed.messageType === 'MT103') + return checkpoint_core_1.MSG_TYPE.MT103; + return checkpoint_core_1.MSG_TYPE.UNKNOWN; + } + if (parsed.format === 'iso20022') { + if (parsed.messageType === 'pacs.008') + return checkpoint_core_1.MSG_TYPE.PACS008; + if (parsed.messageType === 'pain.001') + return checkpoint_core_1.MSG_TYPE.PAIN001; + } + return checkpoint_core_1.MSG_TYPE.UNKNOWN; +} +function msgTypeLabel(parsed) { + if (parsed.format === 'swift_fin' && parsed.messageType === 'MT103') + return 'MT103'; + if (parsed.format === 'iso20022' && parsed.messageType === 'pacs.008') + return 'pacs.008'; + if (parsed.format === 'iso20022' && parsed.messageType === 'pain.001') + return 'pain.001'; + return 'chain138.synthetic'; +} +function pickString(parsed, ...keys) { + const obj = parsed.parsed; + for (const k of keys) { + const v = obj[k]; + if (typeof v === 'string' && v.trim()) + return v.trim(); + } + return ''; +} +function mapToCanonical(parsed, dedupeKey) { + const instructionId = pickString(parsed, 'instructionId', 'senderReference', 'transactionReference', 'messageId') || + `SWIFT-${dedupeKey.slice(0, 16)}`; + const endToEndId = pickString(parsed, 'endToEndId', 'transactionReference', 'senderReference') || instructionId; + const msgId = pickString(parsed, 'messageId', 'senderReference') || instructionId; + const uetr = pickString(parsed, 'uetr') || ''; + const debtorId = pickString(parsed, 'orderingCustomer', 'debtor', 'sendingInstitution') || 'UNKNOWN'; + const creditorId = pickString(parsed, 'beneficiaryCustomer', 'creditor', 'beneficiaryInstitution') || 'UNKNOWN'; + const currencyCode = pickString(parsed, 'currency') || 'USD'; + const amountRaw = pickString(parsed, 'amount') || '0'; + const purpose = pickString(parsed, 'remittanceInfo', 'remittance', 'purpose', 'senderToReceiverInfo', 'narrative') || + `${parsed.format}/${parsed.messageType}`; + const instructionIdBytes32 = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`INSTR:${instructionId}`)); + const uetrBytes32 = uetr + ? ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`UETR:${uetr}`)) + : ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(`UETR:${dedupeKey}`)); + const base = { + msgType: msgTypeLabel(parsed), + msgTypeCode: msgTypeCode(parsed), + instructionId, + instructionIdBytes32, + endToEndId, + endToEndIdHash: (0, checkpoint_core_1.hashShortUtf8)(endToEndId), + msgId, + uetr: uetr || dedupeKey.slice(0, 36), + uetrBytes32, + accountRefId: debtorId, + counterpartyRefId: creditorId, + debtorId, + creditorId, + purpose, + settlementMethod: parsed.format === 'swift_fin' ? 'SWIFT' : 'ISO20022', + categoryPurpose: 'CBFF', + currencyCode, + amountRaw, + amountSmallestUnit: amountRaw.replace(/[,.]/g, '') || '0', + debtorRefHash: (0, checkpoint_core_1.hashShortUtf8)(debtorId), + creditorRefHash: (0, checkpoint_core_1.hashShortUtf8)(creditorId), + purposeHash: (0, checkpoint_core_1.hashShortUtf8)(purpose), + chain138TxHash: pickString(parsed, 'txId', 'chain138TxHash'), + valueDateIso: pickString(parsed, 'valueDate') || undefined, + }; + const payloadHash = ethers_1.ethers.keccak256(ethers_1.ethers.toUtf8Bytes(JSON.stringify({ ...base, sourceFormat: parsed.format, messageType: parsed.messageType }))); + return { ...base, payloadHash }; +} +//# sourceMappingURL=mapToCanonical.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/canonical/mapToCanonical.js.map b/services/swift-listener/dist/canonical/mapToCanonical.js.map new file mode 100644 index 0000000..e82d9dd --- /dev/null +++ b/services/swift-listener/dist/canonical/mapToCanonical.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mapToCanonical.js","sourceRoot":"","sources":["../../src/canonical/mapToCanonical.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/cli.d.ts b/services/swift-listener/dist/cli.d.ts new file mode 100644 index 0000000..faaadd5 --- /dev/null +++ b/services/swift-listener/dist/cli.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=cli.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/cli.d.ts.map b/services/swift-listener/dist/cli.d.ts.map new file mode 100644 index 0000000..f022439 --- /dev/null +++ b/services/swift-listener/dist/cli.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/services/swift-listener/dist/cli.js b/services/swift-listener/dist/cli.js new file mode 100644 index 0000000..ed098f7 --- /dev/null +++ b/services/swift-listener/dist/cli.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const path_1 = require("path"); +const fs_1 = require("fs"); +const listener_1 = require("./listener"); +const resourceRegistry_1 = require("./resourceRegistry"); +function parseArgs(argv) { + let once = false; + let projectRoot = process.env.PROXMOX_ROOT ?? process.cwd(); + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--once') + once = true; + else if (a === '--project-root') + projectRoot = argv[++i] ?? projectRoot; + else if (a.startsWith('--project-root=')) + projectRoot = a.slice('--project-root='.length); + else if (a === '--help' || a === '-h') { + console.log(`Usage: swift-listener [--once] [--project-root PATH] + +Inbound adapters (env flags): + SWIFT_LISTENER_FILE_INBOX=1|0 file drop inbox (default on) + SWIFT_LISTENER_WEBHOOK=1 HTTP POST /swift/inbound + GET /health + SWIFT_LISTENER_P2P_RAIL=1 banktransfer Financial Broadcast poll + SWIFT_LISTENER_FIN_GATEWAY=1 FIN / Alliance Access HTTP poll + SWIFT_LISTENER_FIN_TCP=1 SWIFT FIN copy TCP listener + SWIFT_LISTENER_AS4_INBOX=1 AS4 / ISO drop directory poll + SWIFT_LISTENER_MAIL_INBOX=1 mail attachment drop directory poll + +Outbound (on match): + SWIFT_LISTENER_OMNL_FORWARD=1 local ISO store + token-aggregation API + SWIFT_LISTENER_INTAKE_GATEWAY=1 ISO20022IntakeGateway (dry-run unless EXECUTE=1) + SWIFT_LISTENER_GATEWAY_EXECUTE=1 broadcast submitInbound tx + +Credentials: + P2P_BASE_URL / P2P_BEARER_TOKEN / P2P_API_KEY + FIN_GATEWAY_URL / ALLIANCE_ACCESS_URL / FIN_GATEWAY_TOKEN + SWIFT_FIN_TCP_PORT (default 5001) + SWIFT_LISTENER_WEBHOOK_TOKEN + RPC_URL_138 / PRIVATE_KEY / ISO20022_INTAKE_GATEWAY_MAINNET + TOKEN_AGGREGATION_URL / OMNL_API_KEY +`); + process.exit(0); + } + } + return { once, projectRoot: (0, path_1.resolve)(projectRoot) }; +} +async function main() { + const { once, projectRoot } = parseArgs(process.argv); + const config = (0, resourceRegistry_1.loadListenerConfig)(projectRoot); + const dirs = [ + config.storage.inboxDir, + config.storage.eventsDir, + config.storage.as4InboxDir, + config.storage.mailInboxDir, + ].filter(Boolean); + for (const d of dirs) { + (0, fs_1.mkdirSync)((0, path_1.resolve)(projectRoot, d), { recursive: true }); + } + const listener = (0, listener_1.createSwiftListener)(projectRoot); + await listener.run({ once }); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); +//# sourceMappingURL=cli.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/cli.js.map b/services/swift-listener/dist/cli.js.map new file mode 100644 index 0000000..07d25dc --- /dev/null +++ b/services/swift-listener/dist/cli.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";;;AACA,+BAA+B;AAC/B,2BAA+B;AAC/B,yCAAiD;AACjD,yDAAwD;AAExD,SAAS,SAAS,CAAC,IAAc;IAC/B,IAAI,IAAI,GAAG,KAAK,CAAC;IACjB,IAAI,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAC5D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,IAAI,CAAC,KAAK,QAAQ;YAAE,IAAI,GAAG,IAAI,CAAC;aAC3B,IAAI,CAAC,KAAK,gBAAgB;YAAE,WAAW,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,WAAW,CAAC;aACnE,IAAI,CAAC,CAAC,UAAU,CAAC,iBAAiB,CAAC;YAAE,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC;aACrF,IAAI,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YACtC,OAAO,CAAC,GAAG,CAAC;;;;;;;;;;;;;;;;;;;;;;;CAuBjB,CAAC,CAAC;YACG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,IAAA,cAAO,EAAC,WAAW,CAAC,EAAE,CAAC;AACrD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,IAAA,qCAAkB,EAAC,WAAW,CAAC,CAAC;IAE/C,MAAM,IAAI,GAAG;QACX,MAAM,CAAC,OAAO,CAAC,QAAQ;QACvB,MAAM,CAAC,OAAO,CAAC,SAAS;QACxB,MAAM,CAAC,OAAO,CAAC,WAAW;QAC1B,MAAM,CAAC,OAAO,CAAC,YAAY;KAC5B,CAAC,MAAM,CAAC,OAAO,CAAa,CAAC;IAE9B,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAA,cAAS,EAAC,IAAA,cAAO,EAAC,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,QAAQ,GAAG,IAAA,8BAAmB,EAAC,WAAW,CAAC,CAAC;IAClD,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;AAC/B,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;IACjB,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACjB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/services/swift-listener/dist/index.d.ts b/services/swift-listener/dist/index.d.ts new file mode 100644 index 0000000..9dd4405 --- /dev/null +++ b/services/swift-listener/dist/index.d.ts @@ -0,0 +1,12 @@ +export * from './types'; +export { loadListenerConfig, buildResourceRegistry, applyEnvOverrides } from './resourceRegistry'; +export { parseInbound, parseSwiftFin, parseIso20022 } from './parsers/inboundParser'; +export { matchResources } from './matchers/resourceMatcher'; +export { mapToCanonical } from './canonical/mapToCanonical'; +export { createSwiftListener, SwiftListener } from './listener'; +export { extractFinMessages, startSwiftFinTcpServer } from './adapters/swiftFinTcpAdapter'; +export { createWebhookServer } from './adapters/webhookServer'; +export { forwardToOmnl, OMNL_ISO20022_API_PATH } from './outbound/omnlForwardAdapter'; +export { buildGatewayMessage, submitToIntakeGateway } from './outbound/intakeGatewayAdapter'; +export { dispatchMatchedOutbounds } from './outbound/dispatchOutcomes'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/index.d.ts.map b/services/swift-listener/dist/index.d.ts.map new file mode 100644 index 0000000..2cc21f4 --- /dev/null +++ b/services/swift-listener/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAClG,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACrF,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAC3F,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACtF,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAC7F,OAAO,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC"} \ No newline at end of file diff --git a/services/swift-listener/dist/index.js b/services/swift-listener/dist/index.js new file mode 100644 index 0000000..d6a3565 --- /dev/null +++ b/services/swift-listener/dist/index.js @@ -0,0 +1,47 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dispatchMatchedOutbounds = exports.submitToIntakeGateway = exports.buildGatewayMessage = exports.OMNL_ISO20022_API_PATH = exports.forwardToOmnl = exports.createWebhookServer = exports.startSwiftFinTcpServer = exports.extractFinMessages = exports.SwiftListener = exports.createSwiftListener = exports.mapToCanonical = exports.matchResources = exports.parseIso20022 = exports.parseSwiftFin = exports.parseInbound = exports.applyEnvOverrides = exports.buildResourceRegistry = exports.loadListenerConfig = void 0; +__exportStar(require("./types"), exports); +var resourceRegistry_1 = require("./resourceRegistry"); +Object.defineProperty(exports, "loadListenerConfig", { enumerable: true, get: function () { return resourceRegistry_1.loadListenerConfig; } }); +Object.defineProperty(exports, "buildResourceRegistry", { enumerable: true, get: function () { return resourceRegistry_1.buildResourceRegistry; } }); +Object.defineProperty(exports, "applyEnvOverrides", { enumerable: true, get: function () { return resourceRegistry_1.applyEnvOverrides; } }); +var inboundParser_1 = require("./parsers/inboundParser"); +Object.defineProperty(exports, "parseInbound", { enumerable: true, get: function () { return inboundParser_1.parseInbound; } }); +Object.defineProperty(exports, "parseSwiftFin", { enumerable: true, get: function () { return inboundParser_1.parseSwiftFin; } }); +Object.defineProperty(exports, "parseIso20022", { enumerable: true, get: function () { return inboundParser_1.parseIso20022; } }); +var resourceMatcher_1 = require("./matchers/resourceMatcher"); +Object.defineProperty(exports, "matchResources", { enumerable: true, get: function () { return resourceMatcher_1.matchResources; } }); +var mapToCanonical_1 = require("./canonical/mapToCanonical"); +Object.defineProperty(exports, "mapToCanonical", { enumerable: true, get: function () { return mapToCanonical_1.mapToCanonical; } }); +var listener_1 = require("./listener"); +Object.defineProperty(exports, "createSwiftListener", { enumerable: true, get: function () { return listener_1.createSwiftListener; } }); +Object.defineProperty(exports, "SwiftListener", { enumerable: true, get: function () { return listener_1.SwiftListener; } }); +var swiftFinTcpAdapter_1 = require("./adapters/swiftFinTcpAdapter"); +Object.defineProperty(exports, "extractFinMessages", { enumerable: true, get: function () { return swiftFinTcpAdapter_1.extractFinMessages; } }); +Object.defineProperty(exports, "startSwiftFinTcpServer", { enumerable: true, get: function () { return swiftFinTcpAdapter_1.startSwiftFinTcpServer; } }); +var webhookServer_1 = require("./adapters/webhookServer"); +Object.defineProperty(exports, "createWebhookServer", { enumerable: true, get: function () { return webhookServer_1.createWebhookServer; } }); +var omnlForwardAdapter_1 = require("./outbound/omnlForwardAdapter"); +Object.defineProperty(exports, "forwardToOmnl", { enumerable: true, get: function () { return omnlForwardAdapter_1.forwardToOmnl; } }); +Object.defineProperty(exports, "OMNL_ISO20022_API_PATH", { enumerable: true, get: function () { return omnlForwardAdapter_1.OMNL_ISO20022_API_PATH; } }); +var intakeGatewayAdapter_1 = require("./outbound/intakeGatewayAdapter"); +Object.defineProperty(exports, "buildGatewayMessage", { enumerable: true, get: function () { return intakeGatewayAdapter_1.buildGatewayMessage; } }); +Object.defineProperty(exports, "submitToIntakeGateway", { enumerable: true, get: function () { return intakeGatewayAdapter_1.submitToIntakeGateway; } }); +var dispatchOutcomes_1 = require("./outbound/dispatchOutcomes"); +Object.defineProperty(exports, "dispatchMatchedOutbounds", { enumerable: true, get: function () { return dispatchOutcomes_1.dispatchMatchedOutbounds; } }); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/index.js.map b/services/swift-listener/dist/index.js.map new file mode 100644 index 0000000..8f58310 --- /dev/null +++ b/services/swift-listener/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,0CAAwB;AACxB,uDAAkG;AAAzF,sHAAA,kBAAkB,OAAA;AAAE,yHAAA,qBAAqB,OAAA;AAAE,qHAAA,iBAAiB,OAAA;AACrE,yDAAqF;AAA5E,6GAAA,YAAY,OAAA;AAAE,8GAAA,aAAa,OAAA;AAAE,8GAAA,aAAa,OAAA;AACnD,8DAA4D;AAAnD,iHAAA,cAAc,OAAA;AACvB,6DAA4D;AAAnD,gHAAA,cAAc,OAAA;AACvB,uCAAgE;AAAvD,+GAAA,mBAAmB,OAAA;AAAE,yGAAA,aAAa,OAAA;AAC3C,oEAA2F;AAAlF,wHAAA,kBAAkB,OAAA;AAAE,4HAAA,sBAAsB,OAAA;AACnD,0DAA+D;AAAtD,oHAAA,mBAAmB,OAAA;AAC5B,oEAAsF;AAA7E,mHAAA,aAAa,OAAA;AAAE,4HAAA,sBAAsB,OAAA;AAC9C,wEAA6F;AAApF,2HAAA,mBAAmB,OAAA;AAAE,6HAAA,qBAAqB,OAAA;AACnD,gEAAuE;AAA9D,4HAAA,wBAAwB,OAAA"} \ No newline at end of file diff --git a/services/swift-listener/dist/listener.d.ts b/services/swift-listener/dist/listener.d.ts new file mode 100644 index 0000000..113f9cb --- /dev/null +++ b/services/swift-listener/dist/listener.d.ts @@ -0,0 +1,30 @@ +import type { InboundMessage, SwiftListenerEvent, SwiftListenerOptions } from './types'; +export type { SwiftListenerOptions }; +export declare class SwiftListener { + private readonly projectRoot; + private readonly config; + private readonly registry; + private readonly store; + private lastP2pId?; + private lastFinId?; + private running; + private webhookServer?; + private tcpServer?; + constructor(projectRoot: string); + processMessage(msg: InboundMessage): Promise; + private maybeArchiveSource; + processBatch(sources: InboundMessage[]): Promise; + pollFileInbox(): Promise; + pollAs4Inbox(): Promise; + pollMailInbox(): Promise; + pollP2pRail(): Promise; + pollFinGateway(): Promise; + private trackLastId; + private startLongRunningAdapters; + private pollIntervals; + runPollCycle(): Promise; + run(options?: SwiftListenerOptions): Promise; + shutdown(): void; +} +export declare function createSwiftListener(projectRoot: string): SwiftListener; +//# sourceMappingURL=listener.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/listener.d.ts.map b/services/swift-listener/dist/listener.d.ts.map new file mode 100644 index 0000000..65fe6c2 --- /dev/null +++ b/services/swift-listener/dist/listener.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAExF,YAAY,EAAE,oBAAoB,EAAE,CAAC;AAErC,qBAAa,aAAa;IAUZ,OAAO,CAAC,QAAQ,CAAC,WAAW;IATxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,SAAS,CAAC,CAAS;IAC3B,OAAO,CAAC,SAAS,CAAC,CAAS;IAC3B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,aAAa,CAAC,CAAS;IAC/B,OAAO,CAAC,SAAS,CAAC,CAAa;gBAEF,WAAW,EAAE,MAAM;IAM1C,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IA6D7E,OAAO,CAAC,kBAAkB;IA4BpB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAStE,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAI9C,YAAY,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAQ7C,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAM9C,WAAW,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAe5C,cAAc,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAmBrD,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,wBAAwB;IA4ChC,OAAO,CAAC,aAAa;IAWf,YAAY,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC;IA2C7C,GAAG,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB5D,QAAQ,IAAI,IAAI;CAKjB;AAED,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,aAAa,CAEtE"} \ No newline at end of file diff --git a/services/swift-listener/dist/listener.js b/services/swift-listener/dist/listener.js new file mode 100644 index 0000000..c591f7d --- /dev/null +++ b/services/swift-listener/dist/listener.js @@ -0,0 +1,289 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SwiftListener = void 0; +exports.createSwiftListener = createSwiftListener; +const path_1 = require("path"); +const resourceMatcher_1 = require("./matchers/resourceMatcher"); +const inboundParser_1 = require("./parsers/inboundParser"); +const mapToCanonical_1 = require("./canonical/mapToCanonical"); +const resourceRegistry_1 = require("./resourceRegistry"); +const eventStore_1 = require("./store/eventStore"); +const inboundAdapters_1 = require("./adapters/inboundAdapters"); +const webhookServer_1 = require("./adapters/webhookServer"); +const swiftFinTcpAdapter_1 = require("./adapters/swiftFinTcpAdapter"); +const dispatchOutcomes_1 = require("./outbound/dispatchOutcomes"); +class SwiftListener { + projectRoot; + config; + registry; + store; + lastP2pId; + lastFinId; + running = false; + webhookServer; + tcpServer; + constructor(projectRoot) { + this.projectRoot = projectRoot; + this.config = (0, resourceRegistry_1.loadListenerConfig)(this.projectRoot); + this.registry = (0, resourceRegistry_1.buildResourceRegistry)(this.projectRoot, this.config); + this.store = new eventStore_1.EventStore(this.projectRoot, this.config); + } + async processMessage(msg) { + const dedupeKey = this.store.dedupeKey(msg.source, msg.payload); + if (this.store.isProcessed(dedupeKey)) + return null; + const parsed = (0, inboundParser_1.parseInbound)(msg.payload); + let matches = (0, resourceMatcher_1.matchResources)(this.registry, parsed); + const currency = typeof parsed.parsed.currency === 'string' ? parsed.parsed.currency : undefined; + if ((0, resourceMatcher_1.currencyMatchesCwAsset)(currency, this.registry)) { + matches = [ + ...matches, + { + kind: 'cw_symbol', + value: currency.toUpperCase(), + symbol: currency.toUpperCase(), + field: 'parsed.currency', + }, + ]; + } + const canonical = matches.length > 0 ? (0, mapToCanonical_1.mapToCanonical)(parsed, dedupeKey) : undefined; + let event = (0, eventStore_1.buildEvent)(msg.source, parsed.parsed, parsed.format, String(parsed.messageType), matches, canonical, msg.payload, dedupeKey); + if (event.matched && event.canonical) { + event = { + ...event, + outbound: await (0, dispatchOutcomes_1.dispatchMatchedOutbounds)(this.projectRoot, this.config, this.registry, event), + }; + console.log(JSON.stringify({ + wired: 'match-outbound', + eventId: event.id, + omnlApi: event.outbound?.omnl?.apiPath, + omnlStatus: event.outbound?.omnl?.apiStatus, + gateway: event.outbound?.intakeGateway?.gateway, + gatewayDryRun: event.outbound?.intakeGateway?.dryRun, + gatewayTx: event.outbound?.intakeGateway?.txHash, + })); + } + this.store.saveEvent(event); + this.maybeArchiveSource(msg); + return event; + } + maybeArchiveSource(msg) { + const filePath = msg.metadata?.filePath; + if (typeof filePath !== 'string') + return; + const archiveDir = (0, path_1.resolve)(this.projectRoot, this.config.storage.archiveDir); + if (msg.source.startsWith('file:') && this.config.adapters.fileInbox.archiveOnProcess) { + try { + (0, inboundAdapters_1.archiveInboxFile)(filePath, archiveDir); + } + catch { + // inbox file may already be moved + } + } + if (msg.source.startsWith('as4:') && this.config.adapters.as4Inbox?.archiveOnProcess) { + try { + (0, inboundAdapters_1.archiveInboxFile)(filePath, (0, path_1.resolve)(archiveDir, 'as4')); + } + catch { + // ignore + } + } + if (msg.source.startsWith('mail:') && this.config.adapters.mailAttachmentInbox?.archiveOnProcess) { + try { + (0, inboundAdapters_1.archiveInboxFile)(filePath, (0, path_1.resolve)(archiveDir, 'mail')); + } + catch { + // ignore + } + } + } + async processBatch(sources) { + const out = []; + for (const msg of sources) { + const ev = await this.processMessage(msg); + if (ev) + out.push(ev); + } + return out; + } + async pollFileInbox() { + return this.processBatch((0, inboundAdapters_1.scanFileInbox)(this.store.inboxPath())); + } + async pollAs4Inbox() { + const dir = this.config.storage.as4InboxDir; + if (!dir) + return []; + return this.processBatch((0, inboundAdapters_1.scanDirectoryInbox)((0, path_1.resolve)(this.projectRoot, dir), 'as4', 'iso20022')); + } + async pollMailInbox() { + const dir = this.config.storage.mailInboxDir; + if (!dir) + return []; + return this.processBatch((0, inboundAdapters_1.scanDirectoryInbox)((0, path_1.resolve)(this.projectRoot, dir), 'mail')); + } + async pollP2pRail() { + const cfg = this.config.adapters.p2pRail; + const baseUrl = process.env[cfg.baseUrlEnv] ?? process.env.P2P_BASE_URL ?? ''; + if (!baseUrl) + return []; + const messages = await (0, inboundAdapters_1.pollP2pRailTransactions)({ + baseUrl, + bearerToken: process.env.P2P_BEARER_TOKEN, + apiKey: process.env.P2P_API_KEY, + path: cfg.transactionsPath, + sinceId: this.lastP2pId, + }); + this.trackLastId(messages, 'p2p'); + return this.processBatch(messages); + } + async pollFinGateway() { + const cfg = this.config.adapters.finGateway; + const baseUrl = process.env[cfg.baseUrlEnv] ?? + (cfg.fallbackBaseUrlEnv ? process.env[cfg.fallbackBaseUrlEnv] : undefined) ?? + process.env.ALLIANCE_ACCESS_URL ?? + ''; + if (!baseUrl) + return []; + const tokenEnv = cfg.authBearerEnv ? process.env[cfg.authBearerEnv] : process.env.FIN_GATEWAY_TOKEN; + const messages = await (0, inboundAdapters_1.pollFinGatewayMessages)({ + baseUrl, + bearerToken: tokenEnv, + path: cfg.messagesPath, + sinceId: this.lastFinId, + }); + this.trackLastId(messages, 'fin'); + return this.processBatch(messages); + } + trackLastId(messages, prefix) { + if (messages.length === 0) + return; + const last = messages[messages.length - 1]; + const meta = last.metadata; + const id = String(meta?.id ?? meta?.transactionId ?? meta?.messageId ?? ''); + if (prefix === 'p2p') + this.lastP2pId = id || this.lastP2pId; + else + this.lastFinId = id || this.lastFinId; + } + startLongRunningAdapters() { + const { webhook, swiftFinTcp } = this.config.adapters; + if (webhook.enabled) { + const authEnv = webhook.authBearerEnv ?? 'SWIFT_LISTENER_WEBHOOK_TOKEN'; + const secretEnv = webhook.webhookSecretEnv ?? 'HYBX_WEBHOOK_SECRET'; + const webhookSecret = process.env[secretEnv]?.trim(); + this.webhookServer = (0, webhookServer_1.createWebhookServer)({ + port: webhook.port, + path: webhook.path, + healthPath: webhook.healthPath, + authBearer: webhookSecret ? undefined : process.env[authEnv], + webhookSecret: webhookSecret || undefined, + webhookSignatureHeader: webhook.webhookSignatureHeader, + onMessage: async (msg) => { + await this.processMessage(msg); + }, + }); + console.log(JSON.stringify({ + adapter: 'webhook', + port: webhook.port, + path: webhook.path, + healthPath: webhook.healthPath ?? '/health', + })); + } + if (swiftFinTcp.enabled) { + const port = parseInt(process.env[swiftFinTcp.portEnv] ?? String(swiftFinTcp.defaultPort), 10); + this.tcpServer = (0, swiftFinTcpAdapter_1.startSwiftFinTcpServer)({ + host: swiftFinTcp.host, + port, + onMessage: (msg) => { + void this.processMessage(msg); + }, + }); + console.log(JSON.stringify({ adapter: 'swiftFinTcp', host: swiftFinTcp.host, port })); + } + } + pollIntervals() { + const { adapters } = this.config; + const intervals = []; + if (adapters.fileInbox.enabled) + intervals.push(adapters.fileInbox.pollIntervalSec); + if (adapters.p2pRail.enabled) + intervals.push(adapters.p2pRail.pollIntervalSec); + if (adapters.finGateway?.enabled) + intervals.push(adapters.finGateway.pollIntervalSec); + if (adapters.as4Inbox?.enabled) + intervals.push(adapters.as4Inbox.pollIntervalSec); + if (adapters.mailAttachmentInbox?.enabled) + intervals.push(adapters.mailAttachmentInbox.pollIntervalSec); + return intervals.length ? intervals : [60]; + } + async runPollCycle() { + const events = []; + const { adapters } = this.config; + if (adapters.fileInbox.enabled) { + events.push(...(await this.pollFileInbox())); + } + if (adapters.as4Inbox?.enabled) { + events.push(...(await this.pollAs4Inbox())); + } + if (adapters.mailAttachmentInbox?.enabled) { + events.push(...(await this.pollMailInbox())); + } + if (adapters.p2pRail.enabled) { + try { + events.push(...(await this.pollP2pRail())); + } + catch (e) { + console.error('P2P rail poll error:', e instanceof Error ? e.message : e); + } + } + if (adapters.finGateway?.enabled) { + try { + events.push(...(await this.pollFinGateway())); + } + catch (e) { + console.error('FIN gateway poll error:', e instanceof Error ? e.message : e); + } + } + const matched = events.filter((e) => e.matched); + if (events.length > 0) { + console.log(JSON.stringify({ + at: new Date().toISOString(), + processed: events.length, + matched: matched.length, + types: [...new Set(events.map((e) => e.messageType))], + outbound: matched.filter((e) => e.outbound).length, + })); + } + return events; + } + async run(options = {}) { + if (this.running) + return; + this.running = true; + this.startLongRunningAdapters(); + await this.runPollCycle(); + if (options.once) { + this.shutdown(); + return; + } + const intervalSec = Math.min(...this.pollIntervals()); + const handle = setInterval(() => { + void this.runPollCycle(); + }, intervalSec * 1000); + process.on('SIGINT', () => { + clearInterval(handle); + this.shutdown(); + process.exit(0); + }); + } + shutdown() { + this.webhookServer?.close(); + this.tcpServer?.close(); + this.running = false; + } +} +exports.SwiftListener = SwiftListener; +function createSwiftListener(projectRoot) { + return new SwiftListener(projectRoot); +} +//# sourceMappingURL=listener.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/listener.js.map b/services/swift-listener/dist/listener.js.map new file mode 100644 index 0000000..c095b04 --- /dev/null +++ b/services/swift-listener/dist/listener.js.map @@ -0,0 +1 @@ +{"version":3,"file":"listener.js","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/matchers/resourceMatcher.d.ts b/services/swift-listener/dist/matchers/resourceMatcher.d.ts new file mode 100644 index 0000000..eeab589 --- /dev/null +++ b/services/swift-listener/dist/matchers/resourceMatcher.d.ts @@ -0,0 +1,4 @@ +import type { ParsedInbound, ResourceMatch, ResourceRegistry } from '../types'; +export declare function matchResources(registry: ResourceRegistry, parsed: ParsedInbound): ResourceMatch[]; +export declare function currencyMatchesCwAsset(currency: string | undefined, registry: ResourceRegistry): boolean; +//# sourceMappingURL=resourceMatcher.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/matchers/resourceMatcher.d.ts.map b/services/swift-listener/dist/matchers/resourceMatcher.d.ts.map new file mode 100644 index 0000000..897adce --- /dev/null +++ b/services/swift-listener/dist/matchers/resourceMatcher.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"resourceMatcher.d.ts","sourceRoot":"","sources":["../../src/matchers/resourceMatcher.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAW/E,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,aAAa,GACpB,aAAa,EAAE,CAqFjB;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,gBAAgB,GAAG,OAAO,CAUxG"} \ No newline at end of file diff --git a/services/swift-listener/dist/matchers/resourceMatcher.js b/services/swift-listener/dist/matchers/resourceMatcher.js new file mode 100644 index 0000000..d5bfb0e --- /dev/null +++ b/services/swift-listener/dist/matchers/resourceMatcher.js @@ -0,0 +1,103 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.matchResources = matchResources; +exports.currencyMatchesCwAsset = currencyMatchesCwAsset; +const ethers_1 = require("ethers"); +const resourceRegistry_1 = require("../resourceRegistry"); +const ADDR_RE = /0x[a-fA-F0-9]{40}\b/g; +const CW_SYMBOL_RE = /\bcW[A-Z]{2,6}\b/g; +const C_SYMBOL_RE = /\bc[A-Z]{2,6}\b/g; +function fieldLabel(index, snippet) { + return `text[${index}]:${snippet.slice(0, 40)}`; +} +function matchResources(registry, parsed) { + const matches = []; + const seen = new Set(); + const texts = [ + parsed.searchableText, + ...(0, resourceRegistry_1.collectSearchableFields)(parsed.parsed), + ].filter(Boolean); + const push = (m) => { + const key = `${m.kind}|${m.value.toLowerCase()}|${m.field}`; + if (seen.has(key)) + return; + seen.add(key); + matches.push(m); + }; + texts.forEach((text, idx) => { + const lower = text.toLowerCase(); + for (const name of registry.chainNames) { + if (lower.includes(name)) { + push({ kind: 'chain138_name', value: name, field: fieldLabel(idx, text) }); + } + } + for (const prefix of registry.instructionPrefixes) { + if (text.includes(prefix)) { + push({ kind: 'instruction_prefix', value: prefix, field: fieldLabel(idx, text) }); + } + } + for (const kw of registry.remittanceKeywords) { + if (lower.includes(kw)) { + push({ kind: 'remittance_keyword', value: kw, field: fieldLabel(idx, text) }); + } + } + for (const sym of registry.symbols) { + const re = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); + if (re.test(text)) { + const kind = sym.startsWith('cW') ? 'cw_symbol' : 'chain138_symbol'; + push({ kind, value: sym, symbol: sym, field: fieldLabel(idx, text), chainId: sym.startsWith('cW') ? undefined : 138 }); + } + } + for (const m of text.matchAll(CW_SYMBOL_RE)) { + const sym = m[0]; + if (registry.cwSymbols.has(sym) || registry.symbols.has(sym)) { + push({ kind: 'cw_symbol', value: sym, symbol: sym, field: fieldLabel(idx, text) }); + } + } + for (const m of text.matchAll(C_SYMBOL_RE)) { + const sym = m[0]; + if (registry.symbols.has(sym)) { + push({ kind: 'chain138_symbol', value: sym, symbol: sym, chainId: 138, field: fieldLabel(idx, text) }); + } + } + for (const m of text.matchAll(ADDR_RE)) { + const addr = m[0]; + const meta = registry.addresses.get(addr.toLowerCase()); + if (meta) { + push({ + kind: meta.chainId === 138 ? 'chain138_address' : 'cw_address', + value: ethers_1.ethers.getAddress(addr), + symbol: meta.symbol, + chainId: meta.chainId, + field: fieldLabel(idx, text), + }); + } + } + }); + for (const c of registry.contracts) { + if (parsed.searchableText.toLowerCase().includes(c.toLowerCase())) { + push({ + kind: 'contract_ref', + value: ethers_1.ethers.getAddress(c), + chainId: 138, + field: 'searchableText', + }); + } + } + return matches; +} +function currencyMatchesCwAsset(currency, registry) { + if (!currency) + return false; + const c = currency.toUpperCase(); + for (const sym of registry.cwSymbols) { + if (c === sym.toUpperCase()) + return true; + } + for (const sym of registry.symbols) { + if (c === sym.toUpperCase()) + return true; + } + return false; +} +//# sourceMappingURL=resourceMatcher.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/matchers/resourceMatcher.js.map b/services/swift-listener/dist/matchers/resourceMatcher.js.map new file mode 100644 index 0000000..d179260 --- /dev/null +++ b/services/swift-listener/dist/matchers/resourceMatcher.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resourceMatcher.js","sourceRoot":"","sources":["../../src/matchers/resourceMatcher.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts b/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts new file mode 100644 index 0000000..68d296c --- /dev/null +++ b/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts @@ -0,0 +1,3 @@ +import type { ListenerConfig, ResourceRegistry, SwiftListenerEvent } from '../types'; +export declare function dispatchMatchedOutbounds(projectRoot: string, config: ListenerConfig, registry: ResourceRegistry, event: SwiftListenerEvent): Promise; +//# sourceMappingURL=dispatchOutcomes.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts.map b/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts.map new file mode 100644 index 0000000..8fb8385 --- /dev/null +++ b/services/swift-listener/dist/outbound/dispatchOutcomes.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"dispatchOutcomes.d.ts","sourceRoot":"","sources":["../../src/outbound/dispatchOutcomes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAIrF,wBAAsB,wBAAwB,CAC5C,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC,CAezC"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/dispatchOutcomes.js b/services/swift-listener/dist/outbound/dispatchOutcomes.js new file mode 100644 index 0000000..6015aa3 --- /dev/null +++ b/services/swift-listener/dist/outbound/dispatchOutcomes.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dispatchMatchedOutbounds = dispatchMatchedOutbounds; +const omnlForwardAdapter_1 = require("./omnlForwardAdapter"); +const intakeGatewayAdapter_1 = require("./intakeGatewayAdapter"); +async function dispatchMatchedOutbounds(projectRoot, config, registry, event) { + if (!event.matched || !event.canonical) + return undefined; + const outbound = {}; + const omnlEnabled = config.outbound?.omnlForward?.enabled || config.omnlForward?.enabled; + if (omnlEnabled) { + outbound.omnl = await (0, omnlForwardAdapter_1.forwardToOmnl)(projectRoot, config, event); + } + if (config.outbound?.intakeGateway?.enabled) { + outbound.intakeGateway = await (0, intakeGatewayAdapter_1.submitToIntakeGateway)(config, registry, event.canonical); + } + return outbound; +} +//# sourceMappingURL=dispatchOutcomes.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/dispatchOutcomes.js.map b/services/swift-listener/dist/outbound/dispatchOutcomes.js.map new file mode 100644 index 0000000..5b804c9 --- /dev/null +++ b/services/swift-listener/dist/outbound/dispatchOutcomes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"dispatchOutcomes.js","sourceRoot":"","sources":["../../src/outbound/dispatchOutcomes.ts"],"names":[],"mappings":";;AAIA,4DAoBC;AAvBD,6DAAqD;AACrD,iEAA+D;AAExD,KAAK,UAAU,wBAAwB,CAC5C,WAAmB,EACnB,MAAsB,EACtB,QAA0B,EAC1B,KAAyB;IAEzB,IAAI,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAEzD,MAAM,QAAQ,GAAgD,EAAE,CAAC;IAEjE,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,EAAE,WAAW,EAAE,OAAO,IAAI,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC;IACzF,IAAI,WAAW,EAAE,CAAC;QAChB,QAAQ,CAAC,IAAI,GAAG,MAAM,IAAA,kCAAa,EAAC,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAClE,CAAC;IAED,IAAI,MAAM,CAAC,QAAQ,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC;QAC5C,QAAQ,CAAC,aAAa,GAAG,MAAM,IAAA,4CAAqB,EAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC1F,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts b/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts new file mode 100644 index 0000000..7ad0208 --- /dev/null +++ b/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts @@ -0,0 +1,23 @@ +import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core'; +import type { ListenerConfig, ResourceRegistry } from '../types'; +export declare function buildGatewayMessage(canonical: CanonicalPaymentMessage, registry: ResourceRegistry): { + instructionId: string; + endToEndIdHash: string; + uetr: string; + payloadHash: string; + debtorRefHash: string; + creditorRefHash: string; + purposeHash: string; + msgTypeCode: import("@dbis/checkpoint-core").IsoMessageTypeCode; + token: string; + amount: bigint; + currencyCode: string; +}; +export declare function submitToIntakeGateway(config: ListenerConfig, registry: ResourceRegistry, canonical: CanonicalPaymentMessage): Promise<{ + dryRun: boolean; + txHash?: string; + error?: string; + gateway?: string; + prepared?: boolean; +}>; +//# sourceMappingURL=intakeGatewayAdapter.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts.map b/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts.map new file mode 100644 index 0000000..c8941af --- /dev/null +++ b/services/swift-listener/dist/outbound/intakeGatewayAdapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"intakeGatewayAdapter.d.ts","sourceRoot":"","sources":["../../src/outbound/intakeGatewayAdapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AA+CjE,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,uBAAuB,EAClC,QAAQ,EAAE,gBAAgB;;;;;;;;;;;;EAe3B;AAYD,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,gBAAgB,EAC1B,SAAS,EAAE,uBAAuB,GACjC,OAAO,CAAC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAqCrG"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/intakeGatewayAdapter.js b/services/swift-listener/dist/outbound/intakeGatewayAdapter.js new file mode 100644 index 0000000..95ec6ea --- /dev/null +++ b/services/swift-listener/dist/outbound/intakeGatewayAdapter.js @@ -0,0 +1,102 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildGatewayMessage = buildGatewayMessage; +exports.submitToIntakeGateway = submitToIntakeGateway; +const ethers_1 = require("ethers"); +const GATEWAY_ABI = [ + 'function submitInbound((bytes32 instructionId, bytes32 endToEndIdHash, bytes32 uetr, bytes32 payloadHash, bytes32 debtorRefHash, bytes32 creditorRefHash, bytes32 purposeHash, uint8 msgTypeCode, address token, uint256 amount, bytes3 currencyCode) m) external', + 'function processedInstructions(bytes32) view returns (bool)', +]; +const RPC_TIMEOUT_MS = 15_000; +async function withRpcTimeout(label, fn) { + let timer; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${RPC_TIMEOUT_MS}ms`)), RPC_TIMEOUT_MS); + }), + ]); + } + finally { + if (timer) + clearTimeout(timer); + } +} +function toBytes3(code) { + const b = new Uint8Array(3); + const enc = new TextEncoder().encode(code.slice(0, 3).toUpperCase()); + b.set(enc.slice(0, 3)); + return ethers_1.ethers.hexlify(b); +} +function resolveTokenAddress(canonical, registry) { + if (canonical.tokenAddress && ethers_1.ethers.isAddress(canonical.tokenAddress)) { + return ethers_1.ethers.getAddress(canonical.tokenAddress); + } + for (const sym of [canonical.currencyCode, 'cUSDC', 'cUSDT']) { + for (const [addr, meta] of registry.addresses.entries()) { + if (meta.symbol?.toUpperCase() === sym.toUpperCase() && meta.chainId === 138) { + return ethers_1.ethers.getAddress(addr); + } + } + } + return ethers_1.ethers.ZeroAddress; +} +function buildGatewayMessage(canonical, registry) { + return { + instructionId: canonical.instructionIdBytes32, + endToEndIdHash: canonical.endToEndIdHash, + uetr: canonical.uetrBytes32, + payloadHash: canonical.payloadHash, + debtorRefHash: canonical.debtorRefHash, + creditorRefHash: canonical.creditorRefHash, + purposeHash: canonical.purposeHash, + msgTypeCode: canonical.msgTypeCode, + token: resolveTokenAddress(canonical, registry), + amount: BigInt(canonical.amountSmallestUnit || '0'), + currencyCode: toBytes3(canonical.currencyCode || 'USD'), + }; +} +function shouldExecuteSubmit(gwCfg) { + const explicit = process.env[gwCfg.executeEnv]; + if (explicit === '1') + return true; + if (explicit === '0') + return false; + if (gwCfg.autoExecuteWhenKeyPresent) { + return Boolean(process.env[gwCfg.privateKeyEnv] ?? process.env.PRIVATE_KEY); + } + return !gwCfg.dryRunDefault; +} +async function submitToIntakeGateway(config, registry, canonical) { + const gwCfg = config.outbound?.intakeGateway; + if (!gwCfg?.enabled) + return { dryRun: true }; + const gateway = process.env[gwCfg.gatewayAddressEnv] ?? gwCfg.gatewayAddressFallback; + const rpc = process.env[gwCfg.rpcUrlEnv] ?? process.env.RPC_URL_138 ?? 'http://127.0.0.1:8545'; + const pk = process.env[gwCfg.privateKeyEnv] ?? process.env.PRIVATE_KEY ?? ''; + const execute = shouldExecuteSubmit(gwCfg); + const message = buildGatewayMessage(canonical, registry); + if (!execute) { + return { dryRun: true, gateway, prepared: true }; + } + if (!pk) { + return { dryRun: false, gateway, error: 'PRIVATE_KEY not set' }; + } + try { + const provider = new ethers_1.ethers.JsonRpcProvider(rpc, gwCfg.chainId); + const wallet = new ethers_1.ethers.Wallet(pk, provider); + const contract = new ethers_1.ethers.Contract(gateway, GATEWAY_ABI, wallet); + const already = await withRpcTimeout('processedInstructions', () => contract.processedInstructions(message.instructionId)); + if (already) { + return { dryRun: false, gateway, error: 'duplicate instruction' }; + } + const tx = await withRpcTimeout('submitInbound', () => contract.submitInbound(message)); + const receipt = (await withRpcTimeout('tx.wait', () => tx.wait())); + return { dryRun: false, gateway, txHash: receipt?.hash ?? tx.hash }; + } + catch (e) { + return { dryRun: false, gateway, error: e instanceof Error ? e.message : String(e) }; + } +} +//# sourceMappingURL=intakeGatewayAdapter.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/intakeGatewayAdapter.js.map b/services/swift-listener/dist/outbound/intakeGatewayAdapter.js.map new file mode 100644 index 0000000..519a6dd --- /dev/null +++ b/services/swift-listener/dist/outbound/intakeGatewayAdapter.js.map @@ -0,0 +1 @@ +{"version":3,"file":"intakeGatewayAdapter.js","sourceRoot":"","sources":["../../src/outbound/intakeGatewayAdapter.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts b/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts new file mode 100644 index 0000000..cd9e9fe --- /dev/null +++ b/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts @@ -0,0 +1,9 @@ +import type { ListenerConfig, SwiftListenerEvent } from '../types'; +export declare const OMNL_ISO20022_API_PATH = "/api/v1/omnl/iso20022/messages"; +export declare function forwardToOmnl(projectRoot: string, config: ListenerConfig, event: SwiftListenerEvent): Promise<{ + stored?: string; + apiStatus?: number; + apiPath?: string; + error?: string; +}>; +//# sourceMappingURL=omnlForwardAdapter.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts.map b/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts.map new file mode 100644 index 0000000..f3ce05d --- /dev/null +++ b/services/swift-listener/dist/outbound/omnlForwardAdapter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"omnlForwardAdapter.d.ts","sourceRoot":"","sources":["../../src/outbound/omnlForwardAdapter.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnE,eAAO,MAAM,sBAAsB,mCAAmC,CAAC;AA2BvE,wBAAsB,aAAa,CACjC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAsEpF"} \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/omnlForwardAdapter.js b/services/swift-listener/dist/outbound/omnlForwardAdapter.js new file mode 100644 index 0000000..f9b0e7a --- /dev/null +++ b/services/swift-listener/dist/outbound/omnlForwardAdapter.js @@ -0,0 +1,104 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OMNL_ISO20022_API_PATH = void 0; +exports.forwardToOmnl = forwardToOmnl; +const crypto_1 = require("crypto"); +const fs_1 = require("fs"); +const path_1 = require("path"); +const checkpoint_core_1 = require("@dbis/checkpoint-core"); +exports.OMNL_ISO20022_API_PATH = '/api/v1/omnl/iso20022/messages'; +function mapOmnlMessageType(event) { + const t = event.messageType.toLowerCase(); + if (t.includes('pain.001')) + return 'pain.001'; + if (t.includes('pacs.008') || t === 'mt103') + return 'pacs.008'; + if (t.includes('camt.053')) + return 'camt.053'; + if (event.format === 'swift_fin' && event.messageType === 'MT103') + return 'pacs.008'; + return 'other'; +} +function payloadForStore(event) { + if (event.format === 'iso20022') + return event.rawPreview; + if (event.canonical) + return (0, checkpoint_core_1.canonicalToPacs008Xml)(event.canonical); + return event.rawPreview; +} +function localStoreDir(projectRoot, config) { + const outbound = config.outbound?.omnlForward; + const rel = outbound?.localStoreDir ?? 'config/iso20022-omnl/messages'; + const envDir = process.env.OMNL_ISO20022_STORE_DIR?.trim(); + if (envDir) + return (0, path_1.resolve)(envDir); + return (0, path_1.resolve)(projectRoot, rel); +} +async function forwardToOmnl(projectRoot, config, event) { + const outbound = config.outbound?.omnlForward; + const legacyEnabled = config.omnlForward?.enabled; + if (!outbound?.enabled && !legacyEnabled) + return {}; + const mode = outbound?.mode ?? 'local_and_api'; + const payload = payloadForStore(event); + const messageType = mapOmnlMessageType(event); + const result = {}; + if (mode === 'local' || mode === 'local_and_api') { + const dir = localStoreDir(projectRoot, config); + (0, fs_1.mkdirSync)(dir, { recursive: true }); + const id = (0, crypto_1.randomUUID)(); + const receivedAt = new Date().toISOString(); + const payloadSha256 = (0, crypto_1.createHash)('sha256').update(payload, 'utf8').digest('hex'); + const record = { + id, + messageType, + receivedAt, + retentionUntil: new Date(new Date(receivedAt).setFullYear(new Date(receivedAt).getFullYear() + 10)).toISOString(), + uetr: event.canonical?.uetr, + instructionId: event.canonical?.instructionId, + settlementOrChainRef: event.canonical?.chain138TxHash || undefined, + payloadSha256, + payload, + swiftListenerEventId: event.id, + source: event.source, + }; + (0, fs_1.writeFileSync)((0, path_1.join)(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf8'); + result.stored = id; + } + if (mode === 'api' || mode === 'local_and_api') { + const urlEnv = outbound?.tokenAggregationUrlEnv ?? config.omnlForward?.tokenAggregationUrlEnv ?? 'TOKEN_AGGREGATION_URL'; + const keyEnv = outbound?.apiKeyEnv ?? config.omnlForward?.apiKeyEnv ?? 'OMNL_API_KEY'; + const base = process.env[urlEnv] ?? process.env.TOKEN_AGGREGATION_URL ?? 'http://127.0.0.1:3000'; + const apiKey = process.env[keyEnv] ?? process.env.OMNL_API_KEY; + try { + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (apiKey) + headers['X-API-Key'] = apiKey; + const apiPath = outbound?.apiPath ?? exports.OMNL_ISO20022_API_PATH; + const res = await fetch(`${base.replace(/\/$/, '')}${apiPath}`, { + method: 'POST', + headers, + body: JSON.stringify({ + messageType, + payload, + uetr: event.canonical?.uetr, + instructionId: event.canonical?.instructionId, + settlementOrChainRef: event.canonical?.chain138TxHash, + accountingRef: `swift-listener:${event.id}`, + }), + }); + result.apiStatus = res.status; + result.apiPath = apiPath; + if (!res.ok) + result.error = `OMNL API ${res.status}`; + } + catch (e) { + result.error = e instanceof Error ? e.message : String(e); + } + } + return result; +} +//# sourceMappingURL=omnlForwardAdapter.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/outbound/omnlForwardAdapter.js.map b/services/swift-listener/dist/outbound/omnlForwardAdapter.js.map new file mode 100644 index 0000000..c3cb9d9 --- /dev/null +++ b/services/swift-listener/dist/outbound/omnlForwardAdapter.js.map @@ -0,0 +1 @@ +{"version":3,"file":"omnlForwardAdapter.js","sourceRoot":"","sources":["../../src/outbound/omnlForwardAdapter.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/parsers/inboundParser.d.ts b/services/swift-listener/dist/parsers/inboundParser.d.ts new file mode 100644 index 0000000..439b561 --- /dev/null +++ b/services/swift-listener/dist/parsers/inboundParser.d.ts @@ -0,0 +1,7 @@ +import type { ParsedInbound, ParsedIso20022, ParsedJsonBroadcast, ParsedSwiftFin } from '../types'; +export declare function parseSwiftFin(raw: string): ParsedSwiftFin; +export declare function detectInboundFormat(payload: string): 'swift_fin' | 'iso20022' | 'json_broadcast'; +export declare function parseIso20022(raw: string): ParsedIso20022; +export declare function parseJsonBroadcast(raw: string): ParsedJsonBroadcast; +export declare function parseInbound(payload: string): ParsedInbound; +//# sourceMappingURL=inboundParser.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/parsers/inboundParser.d.ts.map b/services/swift-listener/dist/parsers/inboundParser.d.ts.map new file mode 100644 index 0000000..70f55e4 --- /dev/null +++ b/services/swift-listener/dist/parsers/inboundParser.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"inboundParser.d.ts","sourceRoot":"","sources":["../../src/parsers/inboundParser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,mBAAmB,EAAE,cAAc,EAAuB,MAAM,UAAU,CAAC;AA6IxH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAmCzD;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,GAAG,gBAAgB,CAchG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAwCzD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CAkBnE;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAK3D"} \ No newline at end of file diff --git a/services/swift-listener/dist/parsers/inboundParser.js b/services/swift-listener/dist/parsers/inboundParser.js new file mode 100644 index 0000000..9b0f3f9 --- /dev/null +++ b/services/swift-listener/dist/parsers/inboundParser.js @@ -0,0 +1,264 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseSwiftFin = parseSwiftFin; +exports.detectInboundFormat = detectInboundFormat; +exports.parseIso20022 = parseIso20022; +exports.parseJsonBroadcast = parseJsonBroadcast; +exports.parseInbound = parseInbound; +function parseSwiftFields(text) { + const fields = {}; + let currentField = null; + let currentValue = []; + for (const line of text.split('\n')) { + const m = line.match(/^:(\d{2}[A-Z]?):(.*)$/); + if (m) { + if (currentField) + fields[currentField] = currentValue.join('\n').trim(); + currentField = m[1]; + currentValue = [m[2] ?? '']; + } + else if (currentField) { + currentValue.push(line); + } + } + if (currentField) + fields[currentField] = currentValue.join('\n').trim(); + return fields; +} +function extractBraceBlocks(raw) { + const blocks = new Map(); + const b2 = raw.match(/\{2:([^}]*)\}/); + if (b2) + blocks.set(2, b2[1].trim()); + const b4 = raw.match(/\{4:([\s\S]*?)-\}/); + if (b4) + blocks.set(4, b4[1].trim()); + return blocks; +} +function parseBlock(lines, blockNum, braceBlocks) { + if (braceBlocks?.has(blockNum)) + return braceBlocks.get(blockNum); + const blockStart = `${blockNum}:`; + const blockEnd = `-${blockNum}`; + const out = []; + let inBlock = false; + for (const line of lines) { + if (line.startsWith(blockStart)) { + inBlock = true; + out.push(line.slice(blockStart.length)); + continue; + } + if (line.startsWith(blockEnd)) + break; + if (inBlock) + out.push(line); + } + return out.join('\n'); +} +function parseTextBlock(lines, braceBlocks) { + if (braceBlocks?.has(4)) { + const text = braceBlocks.get(4); + return text.startsWith(':') ? text : `:${text}`; + } + const out = []; + let inText = false; + for (const line of lines) { + if (line.startsWith('4:')) { + inText = true; + out.push(line.slice(2)); + continue; + } + if (line.startsWith('-') && !line.startsWith('-4')) + break; + if (inText) + out.push(line); + } + return out.join('\n'); +} +function extractMessageType(appHeader) { + const mt = appHeader.match(/MT(\d{3})/); + if (mt) + return `MT${mt[1]}`; + const io = appHeader.match(/[IO](\d{3})/); + if (io) + return `MT${io[1]}`; + return 'UNKNOWN'; +} +function split32A(v) { + const parts = v.trim().split(/\s+/); + return { + valueDate: parts[0] ?? '', + currency: parts[1] ?? '', + amount: parts[2] ?? '', + }; +} +function parseMt103(fields) { + const amt = split32A(fields['32A'] ?? ''); + return { + senderReference: fields['20'] ?? '', + bankOperationCode: fields['23B'] ?? '', + ...amt, + orderingCustomer: fields['50A'] ?? fields['50K'] ?? '', + sendingInstitution: fields['52A'] ?? fields['52D'] ?? '', + orderingInstitution: fields['56A'] ?? fields['56C'] ?? fields['56D'] ?? '', + beneficiaryCustomer: fields['59'] ?? fields['59A'] ?? '', + remittanceInfo: fields['70'] ?? '', + senderToReceiverInfo: fields['72'] ?? '', + }; +} +function parseMt202(fields) { + const amt = split32A(fields['32A'] ?? ''); + return { + senderReference: fields['20'] ?? '', + transactionReference: fields['21'] ?? '', + ...amt, + sendingInstitution: fields['52A'] ?? fields['52D'] ?? '', + orderingInstitution: fields['56A'] ?? fields['56C'] ?? fields['56D'] ?? '', + accountWithInstitution: fields['57A'] ?? fields['57C'] ?? fields['57D'] ?? '', + beneficiaryInstitution: fields['58A'] ?? fields['58D'] ?? '', + remittanceInfo: fields['70'] ?? '', + senderToReceiverInfo: fields['72'] ?? '', + }; +} +function parseMt910(fields) { + const amt = split32A(fields['32A'] ?? ''); + return { + transactionReference: fields['20'] ?? '', + relatedReference: fields['21'] ?? '', + ...amt, + orderingInstitution: fields['52A'] ?? fields['52D'] ?? '', + accountWithInstitution: fields['57A'] ?? fields['57D'] ?? '', + remittanceInfo: fields['72'] ?? '', + }; +} +function parseStatement(fields, text) { + return { + statementNumber: fields['28C'] ?? '', + accountIdentification: fields['25'] ?? '', + openingBalance: fields['60F'] ?? fields['60M'] ?? '', + closingBalance: fields['62F'] ?? fields['62M'] ?? '', + transactionLines: (fields['61'] ?? '').split('\n').filter(Boolean), + narrative: fields['86'] ?? '', + rawFieldCount: Object.keys(fields).length, + textLength: text.length, + }; +} +function parseSwiftFin(raw) { + const lines = raw.trim().split('\n'); + const braceBlocks = extractBraceBlocks(raw); + const appHeader = parseBlock(lines, 2, braceBlocks); + const text = parseTextBlock(lines, braceBlocks); + const messageType = extractMessageType(appHeader); + const fields = parseSwiftFields(text.startsWith(':') ? text : `\n${text}`); + let parsed; + switch (messageType) { + case 'MT103': + parsed = parseMt103(fields); + break; + case 'MT202': + parsed = parseMt202(fields); + break; + case 'MT910': + parsed = parseMt910(fields); + break; + case 'MT940': + case 'MT942': + case 'MT950': + parsed = parseStatement(fields, text); + break; + default: + parsed = { fields, textPreview: text.slice(0, 500) }; + } + return { + format: 'swift_fin', + messageType, + raw, + parsed, + searchableText: [text, JSON.stringify(parsed), appHeader].join('\n'), + }; +} +function detectInboundFormat(payload) { + const t = payload.trim(); + if (t.startsWith(' { + const re = new RegExp(`<(?:[\\w-]+:)?${name}[^>]*>([^<]*)`, 'i'); + return raw.match(re)?.[1]?.trim() ?? ''; + }; + const parsed = { + messageId: tag('MsgId'), + instructionId: tag('InstrId'), + endToEndId: tag('EndToEndId'), + uetr: tag('UETR'), + txId: tag('TxId'), + amount: tag('InstdAmt') || tag('IntrBkSttlmAmt'), + currency: raw.match(/<(?:[\w-]+:)?InstdAmt[^>]* Ccy="([^"]+)"/i)?.[1] ?? + raw.match(/<(?:[\w-]+:)?IntrBkSttlmAmt[^>]* Ccy="([^"]+)"/i)?.[1] ?? + '', + debtor: tag('Dbtr') || tag('Nm'), + creditor: tag('Cdtr'), + remittance: tag('Ustrd'), + purpose: tag('Purp'), + }; + return { + format: 'iso20022', + messageType, + raw, + parsed, + searchableText: raw, + }; +} +function parseJsonBroadcast(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } + catch { + parsed = { raw }; + } + const messageType = String(parsed.messageType ?? parsed.type ?? parsed.channel ?? parsed.status ?? 'json_broadcast'); + return { + format: 'json_broadcast', + messageType, + raw, + parsed, + searchableText: JSON.stringify(parsed), + }; +} +function parseInbound(payload) { + const format = detectInboundFormat(payload); + if (format === 'swift_fin') + return parseSwiftFin(payload); + if (format === 'iso20022') + return parseIso20022(payload); + return parseJsonBroadcast(payload); +} +//# sourceMappingURL=inboundParser.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/parsers/inboundParser.js.map b/services/swift-listener/dist/parsers/inboundParser.js.map new file mode 100644 index 0000000..79f3324 --- /dev/null +++ b/services/swift-listener/dist/parsers/inboundParser.js.map @@ -0,0 +1 @@ +{"version":3,"file":"inboundParser.js","sourceRoot":"","sources":["../../src/parsers/inboundParser.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/resourceRegistry.d.ts b/services/swift-listener/dist/resourceRegistry.d.ts new file mode 100644 index 0000000..e39f031 --- /dev/null +++ b/services/swift-listener/dist/resourceRegistry.d.ts @@ -0,0 +1,7 @@ +import type { ListenerConfig, ResourceRegistry } from './types'; +export declare function loadListenerConfig(projectRoot: string): ListenerConfig; +export declare function applyEnvOverrides(config: ListenerConfig): void; +/** Build searchable registry from Chain 138 + cW* config sources. */ +export declare function buildResourceRegistry(projectRoot: string, config: ListenerConfig): ResourceRegistry; +export declare function collectSearchableFields(parsed: Record, prefix?: string): string[]; +//# sourceMappingURL=resourceRegistry.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/resourceRegistry.d.ts.map b/services/swift-listener/dist/resourceRegistry.d.ts.map new file mode 100644 index 0000000..b4009c7 --- /dev/null +++ b/services/swift-listener/dist/resourceRegistry.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"resourceRegistry.d.ts","sourceRoot":"","sources":["../src/resourceRegistry.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAWhE,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAKtE;AAMD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAwB9D;AAsCD,qEAAqE;AACrE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,gBAAgB,CAmGnG;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,SAAK,GAAG,MAAM,EAAE,CAiB9F"} \ No newline at end of file diff --git a/services/swift-listener/dist/resourceRegistry.js b/services/swift-listener/dist/resourceRegistry.js new file mode 100644 index 0000000..97fbc2c --- /dev/null +++ b/services/swift-listener/dist/resourceRegistry.js @@ -0,0 +1,195 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadListenerConfig = loadListenerConfig; +exports.applyEnvOverrides = applyEnvOverrides; +exports.buildResourceRegistry = buildResourceRegistry; +exports.collectSearchableFields = collectSearchableFields; +const fs_1 = require("fs"); +const path_1 = require("path"); +function readJson(path) { + if (!(0, fs_1.existsSync)(path)) + return null; + return JSON.parse((0, fs_1.readFileSync)(path, 'utf8')); +} +function normAddr(a) { + return a.toLowerCase(); +} +function loadListenerConfig(projectRoot) { + const p = (0, path_1.resolve)(projectRoot, 'config/swift-listener-chain138.v1.json'); + const config = readJson(p); + applyEnvOverrides(config); + return config; +} +function envFlag(name) { + return process.env[name] === '1'; +} +function applyEnvOverrides(config) { + if (process.env.SWIFT_LISTENER_FILE_INBOX === '1') + config.adapters.fileInbox.enabled = true; + if (process.env.SWIFT_LISTENER_FILE_INBOX === '0') + config.adapters.fileInbox.enabled = false; + if (envFlag('SWIFT_LISTENER_WEBHOOK')) + config.adapters.webhook.enabled = true; + if (process.env.SWIFT_LISTENER_WEBHOOK === '0') + config.adapters.webhook.enabled = false; + if (envFlag('SWIFT_LISTENER_P2P_RAIL')) + config.adapters.p2pRail.enabled = true; + if (envFlag('SWIFT_LISTENER_FIN_GATEWAY')) + config.adapters.finGateway.enabled = true; + if (envFlag('SWIFT_LISTENER_FIN_TCP')) + config.adapters.swiftFinTcp.enabled = true; + if (process.env.SWIFT_LISTENER_FIN_TCP === '0') + config.adapters.swiftFinTcp.enabled = false; + if (envFlag('SWIFT_LISTENER_AS4_INBOX')) + config.adapters.as4Inbox.enabled = true; + if (envFlag('SWIFT_LISTENER_MAIL_INBOX')) + config.adapters.mailAttachmentInbox.enabled = true; + config.outbound ??= defaultOutboundConfig(); + if (envFlag('SWIFT_LISTENER_OMNL_FORWARD')) + config.outbound.omnlForward.enabled = true; + if (process.env.SWIFT_LISTENER_OMNL_FORWARD === '0') + config.outbound.omnlForward.enabled = false; + if (envFlag('SWIFT_LISTENER_INTAKE_GATEWAY')) + config.outbound.intakeGateway.enabled = true; + if (process.env.SWIFT_LISTENER_INTAKE_GATEWAY === '0') + config.outbound.intakeGateway.enabled = false; + applyAttestationEnvFlags(config); +} +/** Align with config/chain138-iso20022-attestation.v1.json envFlags when set on operator host. */ +function applyAttestationEnvFlags(config) { + if (process.env.CHECKPOINT_ISO_OMNL_STORE === '1') { + config.outbound ??= defaultOutboundConfig(); + config.outbound.omnlForward.enabled = true; + } + if (process.env.CHECKPOINT_RECORD_ISO_ATTESTATION === '1') { + config.outbound ??= defaultOutboundConfig(); + config.outbound.intakeGateway.enabled = true; + } +} +function defaultOutboundConfig() { + return { + omnlForward: { + enabled: true, + mode: 'local_and_api', + apiPath: '/api/v1/omnl/iso20022/messages', + tokenAggregationUrlEnv: 'TOKEN_AGGREGATION_URL', + apiKeyEnv: 'OMNL_API_KEY', + localStoreDir: 'config/iso20022-omnl/messages', + }, + intakeGateway: { + enabled: true, + gatewayAddressEnv: 'ISO20022_INTAKE_GATEWAY_MAINNET', + gatewayAddressFallback: '0x66C89F91A4e830d87fcE4E5F86aFA71A96FbDa3d', + rpcUrlEnv: 'RPC_URL_138', + privateKeyEnv: 'PRIVATE_KEY', + executeEnv: 'SWIFT_LISTENER_GATEWAY_EXECUTE', + dryRunDefault: false, + autoExecuteWhenKeyPresent: true, + chainId: 138, + }, + }; +} +/** Build searchable registry from Chain 138 + cW* config sources. */ +function buildResourceRegistry(projectRoot, config) { + const symbols = new Set(); + const cwSymbols = new Set(Object.values(config.cToCwSymbolMapping)); + const addresses = new Map(); + const contracts = []; + for (const [cSym, cwSym] of Object.entries(config.cToCwSymbolMapping)) { + symbols.add(cSym); + symbols.add(cwSym); + cwSymbols.add(cwSym); + } + const ei = readJson((0, path_1.resolve)(projectRoot, 'config/ei-matrix-138-assets-and-pools.v1.json')); + if (ei?.canonicalTokens) { + for (const t of ei.canonicalTokens) { + symbols.add(t.symbol); + addresses.set(normAddr(t.address), { symbol: t.symbol, chainId: 138 }); + } + } + if (ei?.dodoStackA?.integration) { + contracts.push(ei.dodoStackA.integration); + addresses.set(normAddr(ei.dodoStackA.integration), { symbol: 'DODOPMMIntegration', chainId: 138 }); + } + if (ei?.dodoStackA?.provider) { + contracts.push(ei.dodoStackA.provider); + addresses.set(normAddr(ei.dodoStackA.provider), { symbol: 'DODOPMMProvider', chainId: 138 }); + } + for (const pool of ei?.dodoStackA?.pools ?? []) { + contracts.push(pool.pool); + addresses.set(normAddr(pool.pool), { symbol: pool.name ?? 'PMM_POOL', chainId: 138 }); + } + if (ei?.enhancedSwapRouter) { + contracts.push(ei.enhancedSwapRouter); + addresses.set(normAddr(ei.enhancedSwapRouter), { symbol: 'EnhancedSwapRouter', chainId: 138 }); + } + if (ei?.enhancedSwapRouterV2) { + contracts.push(ei.enhancedSwapRouterV2); + addresses.set(normAddr(ei.enhancedSwapRouterV2), { symbol: 'EnhancedSwapRouterV2', chainId: 138 }); + } + for (const pool of ei?.uniswapV3Native?.pools ?? []) { + contracts.push(pool.pool); + addresses.set(normAddr(pool.pool), { symbol: 'UniV3Pool', chainId: 138 }); + } + const attestation = readJson((0, path_1.resolve)(projectRoot, 'config/chain138-iso20022-attestation.v1.json')); + for (const addr of Object.values(attestation?.contracts ?? {})) { + if (addr.startsWith('0x') && addr.length === 42) { + contracts.push(addr); + addresses.set(normAddr(addr), { chainId: 138 }); + } + } + const mapping = readJson((0, path_1.resolve)(projectRoot, 'config/token-mapping-multichain.json')); + for (const [c, cw] of Object.entries(mapping?.cToCwSymbolMapping ?? config.cToCwSymbolMapping)) { + symbols.add(c); + symbols.add(cw); + cwSymbols.add(cw); + } + for (const pair of mapping?.pairs ?? []) { + for (const tok of pair.tokens ?? []) { + if (tok.addressFrom?.startsWith('0x') && tok.addressFrom.length === 42 && pair.fromChainId === 138) { + addresses.set(normAddr(tok.addressFrom), { symbol: tok.key, chainId: 138 }); + } + if (tok.addressTo?.startsWith('0x') && tok.addressTo.length === 42 && tok.addressTo !== '0x0000000000000000000000000000000000000000') { + const sym = tok.key.includes('_cW') ? config.cToCwSymbolMapping[tok.key.replace('Compliant_', '').replace('_cW', '')] ?? tok.key : tok.key; + addresses.set(normAddr(tok.addressTo), { symbol: sym, chainId: pair.toChainId }); + if (String(sym).startsWith('cW')) + cwSymbols.add(String(sym)); + } + } + } + return { + chainId: config.chain138.chainId, + chainNames: config.chain138.names.map((n) => n.toLowerCase()), + symbols, + cwSymbols, + addresses, + instructionPrefixes: config.matchHints.instructionIdPrefixes, + remittanceKeywords: config.matchHints.remittanceKeywords.map((k) => k.toLowerCase()), + contracts, + }; +} +function collectSearchableFields(parsed, prefix = '') { + const out = []; + for (const [k, v] of Object.entries(parsed)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v == null) + continue; + if (typeof v === 'string') + out.push(v); + else if (typeof v === 'number' || typeof v === 'boolean') + out.push(String(v)); + else if (Array.isArray(v)) { + for (const item of v) { + if (typeof item === 'string') + out.push(item); + else if (item && typeof item === 'object') + out.push(...collectSearchableFields(item, path)); + } + } + else if (typeof v === 'object') { + out.push(...collectSearchableFields(v, path)); + } + } + return out; +} +//# sourceMappingURL=resourceRegistry.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/resourceRegistry.js.map b/services/swift-listener/dist/resourceRegistry.js.map new file mode 100644 index 0000000..4c2b970 --- /dev/null +++ b/services/swift-listener/dist/resourceRegistry.js.map @@ -0,0 +1 @@ +{"version":3,"file":"resourceRegistry.js","sourceRoot":"","sources":["../src/resourceRegistry.ts"],"names":[],"mappings":";;AAaA,gDAKC;AAMD,8CAwBC;AAuCD,sDAmGC;AAED,0DAiBC;AA7MD,2BAA8C;AAC9C,+BAA+B;AAG/B,SAAS,QAAQ,CAAI,IAAY;IAC/B,IAAI,CAAC,IAAA,eAAU,EAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,IAAI,EAAE,MAAM,CAAC,CAAM,CAAC;AACrD,CAAC;AAED,SAAS,QAAQ,CAAC,CAAS;IACzB,OAAO,CAAC,CAAC,WAAW,EAAE,CAAC;AACzB,CAAC;AAED,SAAgB,kBAAkB,CAAC,WAAmB;IACpD,MAAM,CAAC,GAAG,IAAA,cAAO,EAAC,WAAW,EAAE,wCAAwC,CAAC,CAAC;IACzE,MAAM,MAAM,GAAG,QAAQ,CAAiB,CAAC,CAAE,CAAC;IAC5C,iBAAiB,CAAC,MAAM,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC;AACnC,CAAC;AAED,SAAgB,iBAAiB,CAAC,MAAsB;IACtD,IAAI,OAAO,CAAC,GAAG,CAAC,yBAAyB,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC;IAC5F,IAAI,OAAO,CAAC,GAAG,CAAC,yBAAyB,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,OAAO,GAAG,KAAK,CAAC;IAE7F,IAAI,OAAO,CAAC,wBAAwB,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9E,IAAI,OAAO,CAAC,GAAG,CAAC,sBAAsB,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,GAAG,KAAK,CAAC;IAExF,IAAI,OAAO,CAAC,yBAAyB,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAC/E,IAAI,OAAO,CAAC,4BAA4B,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,OAAO,GAAG,IAAI,CAAC;IACrF,IAAI,OAAO,CAAC,wBAAwB,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;IAClF,IAAI,OAAO,CAAC,GAAG,CAAC,sBAAsB,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,OAAO,GAAG,KAAK,CAAC;IAE5F,IAAI,OAAO,CAAC,0BAA0B,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAC;IACjF,IAAI,OAAO,CAAC,2BAA2B,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,GAAG,IAAI,CAAC;IAE7F,MAAM,CAAC,QAAQ,KAAK,qBAAqB,EAAE,CAAC;IAE5C,IAAI,OAAO,CAAC,6BAA6B,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;IACvF,IAAI,OAAO,CAAC,GAAG,CAAC,2BAA2B,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,OAAO,GAAG,KAAK,CAAC;IAEjG,IAAI,OAAO,CAAC,+BAA+B,CAAC;QAAE,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,OAAO,GAAG,IAAI,CAAC;IAC3F,IAAI,OAAO,CAAC,GAAG,CAAC,6BAA6B,KAAK,GAAG;QAAE,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,OAAO,GAAG,KAAK,CAAC;IAErG,wBAAwB,CAAC,MAAM,CAAC,CAAC;AACnC,CAAC;AAED,kGAAkG;AAClG,SAAS,wBAAwB,CAAC,MAAsB;IACtD,IAAI,OAAO,CAAC,GAAG,CAAC,yBAAyB,KAAK,GAAG,EAAE,CAAC;QAClD,MAAM,CAAC,QAAQ,KAAK,qBAAqB,EAAE,CAAC;QAC5C,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;IAC7C,CAAC;IACD,IAAI,OAAO,CAAC,GAAG,CAAC,iCAAiC,KAAK,GAAG,EAAE,CAAC;QAC1D,MAAM,CAAC,QAAQ,KAAK,qBAAqB,EAAE,CAAC;QAC5C,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,OAAO,GAAG,IAAI,CAAC;IAC/C,CAAC;AACH,CAAC;AAED,SAAS,qBAAqB;IAC5B,OAAO;QACL,WAAW,EAAE;YACX,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,eAAe;YACrB,OAAO,EAAE,gCAAgC;YACzC,sBAAsB,EAAE,uBAAuB;YAC/C,SAAS,EAAE,cAAc;YACzB,aAAa,EAAE,+BAA+B;SAC/C;QACD,aAAa,EAAE;YACb,OAAO,EAAE,IAAI;YACb,iBAAiB,EAAE,iCAAiC;YACpD,sBAAsB,EAAE,4CAA4C;YACpE,SAAS,EAAE,aAAa;YACxB,aAAa,EAAE,aAAa;YAC5B,UAAU,EAAE,gCAAgC;YAC5C,aAAa,EAAE,KAAK;YACpB,yBAAyB,EAAE,IAAI;YAC/B,OAAO,EAAE,GAAG;SACb;KACF,CAAC;AACJ,CAAC;AAED,qEAAqE;AACrE,SAAgB,qBAAqB,CAAC,WAAmB,EAAE,MAAsB;IAC/E,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,MAAM,SAAS,GAAG,IAAI,GAAG,CAAS,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC,CAAC;IAC5E,MAAM,SAAS,GAAG,IAAI,GAAG,EAAiD,CAAC;IAC3E,MAAM,SAAS,GAAa,EAAE,CAAC;IAE/B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,kBAAkB,CAAC,EAAE,CAAC;QACtE,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAClB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACnB,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACvB,CAAC;IAED,MAAM,EAAE,GAAG,QAAQ,CAMhB,IAAA,cAAO,EAAC,WAAW,EAAE,+CAA+C,CAAC,CAAC,CAAC;IAE1E,IAAI,EAAE,EAAE,eAAe,EAAE,CAAC;QACxB,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,eAAe,EAAE,CAAC;YACnC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YACtB,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;QACzE,CAAC;IACH,CAAC;IACD,IAAI,EAAE,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC;QAChC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;QAC1C,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IACrG,CAAC;IACD,IAAI,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC;QAC7B,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;QACvC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IAC/F,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,EAAE,EAAE,UAAU,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;QAC/C,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1B,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,IAAI,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IACxF,CAAC;IACD,IAAI,EAAE,EAAE,kBAAkB,EAAE,CAAC;QAC3B,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,kBAAkB,CAAC,CAAC;QACtC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,kBAAkB,CAAC,EAAE,EAAE,MAAM,EAAE,oBAAoB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IACjG,CAAC;IACD,IAAI,EAAE,EAAE,oBAAoB,EAAE,CAAC;QAC7B,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,oBAAoB,CAAC,CAAC;QACxC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,oBAAoB,CAAC,EAAE,EAAE,MAAM,EAAE,sBAAsB,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IACrG,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,EAAE,EAAE,eAAe,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;QACpD,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1B,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;IAC5E,CAAC;IAED,MAAM,WAAW,GAAG,QAAQ,CAC1B,IAAA,cAAO,EAAC,WAAW,EAAE,8CAA8C,CAAC,CACrE,CAAC;IACF,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,SAAS,IAAI,EAAE,CAAC,EAAE,CAAC;QAC/D,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;YAChD,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrB,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAAG,QAAQ,CAOrB,IAAA,cAAO,EAAC,WAAW,EAAE,sCAAsC,CAAC,CAAC,CAAC;IAEjE,KAAK,MAAM,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,kBAAkB,IAAI,MAAM,CAAC,kBAAkB,CAAC,EAAE,CAAC;QAC/F,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACf,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACpB,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,KAAK,IAAI,EAAE,EAAE,CAAC;QACxC,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC;YACpC,IAAI,GAAG,CAAC,WAAW,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,WAAW,CAAC,MAAM,KAAK,EAAE,IAAI,IAAI,CAAC,WAAW,KAAK,GAAG,EAAE,CAAC;gBACnG,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC;YAC9E,CAAC;YACD,IAAI,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,SAAS,CAAC,MAAM,KAAK,EAAE,IAAI,GAAG,CAAC,SAAS,KAAK,4CAA4C,EAAE,CAAC;gBACrI,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;gBAC3I,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;gBACjF,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC;oBAAE,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YAC/D,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,OAAO;QAChC,UAAU,EAAE,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;QAC7D,OAAO;QACP,SAAS;QACT,SAAS;QACT,mBAAmB,EAAE,MAAM,CAAC,UAAU,CAAC,qBAAqB;QAC5D,kBAAkB,EAAE,MAAM,CAAC,UAAU,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;QACpF,SAAS;KACV,CAAC;AACJ,CAAC;AAED,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAM,GAAG,EAAE;IAClF,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3C,IAAI,CAAC,IAAI,IAAI;YAAE,SAAS;QACxB,IAAI,OAAO,CAAC,KAAK,QAAQ;YAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;aAClC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,KAAK,SAAS;YAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;aACzE,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1B,KAAK,MAAM,IAAI,IAAI,CAAC,EAAE,CAAC;gBACrB,IAAI,OAAO,IAAI,KAAK,QAAQ;oBAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;qBACxC,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;oBAAE,GAAG,CAAC,IAAI,CAAC,GAAG,uBAAuB,CAAC,IAA+B,EAAE,IAAI,CAAC,CAAC,CAAC;YACzH,CAAC;QACH,CAAC;aAAM,IAAI,OAAO,CAAC,KAAK,QAAQ,EAAE,CAAC;YACjC,GAAG,CAAC,IAAI,CAAC,GAAG,uBAAuB,CAAC,CAA4B,EAAE,IAAI,CAAC,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"} \ No newline at end of file diff --git a/services/swift-listener/dist/store/eventStore.d.ts b/services/swift-listener/dist/store/eventStore.d.ts new file mode 100644 index 0000000..29f2b2c --- /dev/null +++ b/services/swift-listener/dist/store/eventStore.d.ts @@ -0,0 +1,17 @@ +import type { ListenerConfig, SwiftListenerEvent } from '../types'; +export declare class EventStore { + private readonly projectRoot; + private readonly config; + private processed; + constructor(projectRoot: string, config: ListenerConfig); + private paths; + private loadProcessedIndex; + private persistProcessedIndex; + dedupeKey(source: string, payload: string): string; + isProcessed(key: string): boolean; + saveEvent(event: SwiftListenerEvent): void; + inboxPath(): string; + archiveFile(fromPath: string): void; +} +export declare function buildEvent(source: string, parsed: SwiftListenerEvent['parsed'], format: SwiftListenerEvent['format'], messageType: string, matches: SwiftListenerEvent['matches'], canonical: SwiftListenerEvent['canonical'], raw: string, dedupeKey: string): SwiftListenerEvent; +//# sourceMappingURL=eventStore.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/store/eventStore.d.ts.map b/services/swift-listener/dist/store/eventStore.d.ts.map new file mode 100644 index 0000000..8755cd1 --- /dev/null +++ b/services/swift-listener/dist/store/eventStore.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"eventStore.d.ts","sourceRoot":"","sources":["../../src/store/eventStore.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAMnE,qBAAa,UAAU;IAInB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJzB,OAAO,CAAC,SAAS,CAAqB;gBAGnB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,cAAc;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,kBAAkB;IAW1B,OAAO,CAAC,qBAAqB;IAM7B,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAIlD,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIjC,SAAS,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI;IA2B1C,SAAS,IAAI,MAAM;IAInB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;CAMpC;AAED,wBAAgB,UAAU,CACxB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,EACpC,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,EACpC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,kBAAkB,CAAC,SAAS,CAAC,EACtC,SAAS,EAAE,kBAAkB,CAAC,WAAW,CAAC,EAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAcpB"} \ No newline at end of file diff --git a/services/swift-listener/dist/store/eventStore.js b/services/swift-listener/dist/store/eventStore.js new file mode 100644 index 0000000..24db024 --- /dev/null +++ b/services/swift-listener/dist/store/eventStore.js @@ -0,0 +1,100 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EventStore = void 0; +exports.buildEvent = buildEvent; +const crypto_1 = require("crypto"); +const fs_1 = require("fs"); +const path_1 = require("path"); +function ensureDir(path) { + (0, fs_1.mkdirSync)(path, { recursive: true }); +} +class EventStore { + projectRoot; + config; + processed = new Set(); + constructor(projectRoot, config) { + this.projectRoot = projectRoot; + this.config = config; + this.loadProcessedIndex(); + } + paths() { + const s = this.config.storage; + return { + eventsDir: (0, path_1.resolve)(this.projectRoot, s.eventsDir), + latestStatus: (0, path_1.resolve)(this.projectRoot, s.latestStatus), + processedIndex: (0, path_1.resolve)(this.projectRoot, s.processedIndex), + inboxDir: (0, path_1.resolve)(this.projectRoot, s.inboxDir), + archiveDir: (0, path_1.resolve)(this.projectRoot, s.archiveDir), + }; + } + loadProcessedIndex() { + const { processedIndex } = this.paths(); + if (!(0, fs_1.existsSync)(processedIndex)) + return; + try { + const data = JSON.parse((0, fs_1.readFileSync)(processedIndex, 'utf8')); + for (const k of data.keys ?? []) + this.processed.add(k); + } + catch { + // fresh index on parse failure + } + } + persistProcessedIndex() { + const { processedIndex } = this.paths(); + ensureDir((0, path_1.dirname)(processedIndex)); + (0, fs_1.writeFileSync)(processedIndex, JSON.stringify({ keys: [...this.processed].slice(-50000) }, null, 2)); + } + dedupeKey(source, payload) { + return (0, crypto_1.createHash)('sha256').update(`${source}\n${payload}`).digest('hex'); + } + isProcessed(key) { + return this.processed.has(key); + } + saveEvent(event) { + const { eventsDir, latestStatus } = this.paths(); + ensureDir(eventsDir); + const eventPath = (0, path_1.join)(eventsDir, `${event.id}.json`); + (0, fs_1.writeFileSync)(eventPath, JSON.stringify(event, null, 2)); + const status = { + updatedAt: new Date().toISOString(), + lastEventId: event.id, + lastMatched: event.matched, + lastMessageType: event.messageType, + lastSource: event.source, + matchCount: event.matches.length, + outbound: event.outbound ?? null, + }; + ensureDir((0, path_1.dirname)(latestStatus)); + (0, fs_1.writeFileSync)(latestStatus, JSON.stringify(status, null, 2)); + this.processed.add(event.dedupeKey); + this.persistProcessedIndex(); + (0, fs_1.appendFileSync)((0, path_1.join)(eventsDir, '..', 'events.jsonl'), `${JSON.stringify({ id: event.id, at: event.receivedAt, matched: event.matched, type: event.messageType, source: event.source })}\n`); + } + inboxPath() { + return this.paths().inboxDir; + } + archiveFile(fromPath) { + const { archiveDir } = this.paths(); + ensureDir(archiveDir); + const base = fromPath.split('/').pop() ?? (0, crypto_1.randomUUID)(); + (0, fs_1.renameSync)(fromPath, (0, path_1.join)(archiveDir, `${Date.now()}-${base}`)); + } +} +exports.EventStore = EventStore; +function buildEvent(source, parsed, format, messageType, matches, canonical, raw, dedupeKey) { + return { + id: (0, crypto_1.randomUUID)(), + receivedAt: new Date().toISOString(), + source, + format, + messageType, + matched: matches.length > 0, + matches, + parsed, + canonical, + rawPreview: raw.slice(0, 2000), + dedupeKey, + }; +} +//# sourceMappingURL=eventStore.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/store/eventStore.js.map b/services/swift-listener/dist/store/eventStore.js.map new file mode 100644 index 0000000..aee70b7 --- /dev/null +++ b/services/swift-listener/dist/store/eventStore.js.map @@ -0,0 +1 @@ +{"version":3,"file":"eventStore.js","sourceRoot":"","sources":["../../src/store/eventStore.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/types.d.ts b/services/swift-listener/dist/types.d.ts new file mode 100644 index 0000000..ba3e778 --- /dev/null +++ b/services/swift-listener/dist/types.d.ts @@ -0,0 +1,185 @@ +import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core'; +export type SwiftFinMessageType = 'MT103' | 'MT202' | 'MT910' | 'MT940' | 'MT942' | 'MT950' | 'UNKNOWN'; +export type Iso20022MessageType = 'pain.001' | 'pacs.008' | 'pacs.009' | 'pacs.002' | 'camt.052' | 'camt.053' | 'camt.054' | 'unknown'; +export type InboundFormat = 'swift_fin' | 'iso20022' | 'json_broadcast'; +export type ResourceMatchKind = 'chain138_name' | 'chain138_address' | 'chain138_symbol' | 'cw_symbol' | 'cw_address' | 'instruction_prefix' | 'remittance_keyword' | 'contract_ref'; +export type ResourceMatch = { + kind: ResourceMatchKind; + value: string; + field: string; + symbol?: string; + chainId?: number; +}; +export type ParsedSwiftFin = { + format: 'swift_fin'; + messageType: SwiftFinMessageType; + raw: string; + parsed: Record; + searchableText: string; +}; +export type ParsedIso20022 = { + format: 'iso20022'; + messageType: Iso20022MessageType; + raw: string; + parsed: Record; + searchableText: string; +}; +export type ParsedJsonBroadcast = { + format: 'json_broadcast'; + messageType: string; + raw: string; + parsed: Record; + searchableText: string; +}; +export type ParsedInbound = ParsedSwiftFin | ParsedIso20022 | ParsedJsonBroadcast; +export type SwiftListenerEvent = { + id: string; + receivedAt: string; + source: string; + format: InboundFormat; + messageType: string; + matched: boolean; + matches: ResourceMatch[]; + parsed: Record; + canonical?: CanonicalPaymentMessage; + rawPreview: string; + dedupeKey: string; + outbound?: OutboundDispatchResult; +}; +export type OutboundDispatchResult = { + omnl?: { + stored?: string; + apiStatus?: number; + apiPath?: string; + error?: string; + }; + intakeGateway?: { + dryRun: boolean; + txHash?: string; + error?: string; + gateway?: string; + prepared?: boolean; + }; +}; +export type ListenerConfig = { + schemaVersion: string; + chain138: { + chainId: number; + names: string[]; + rpcHint?: string; + configSources?: string[]; + }; + supportedSwiftFinTypes: SwiftFinMessageType[]; + supportedIso20022Types: Iso20022MessageType[]; + cToCwSymbolMapping: Record; + matchHints: { + instructionIdPrefixes: string[]; + remittanceKeywords: string[]; + }; + storage: { + eventsDir: string; + latestStatus: string; + processedIndex: string; + inboxDir: string; + archiveDir: string; + as4InboxDir?: string; + mailInboxDir?: string; + }; + adapters: { + fileInbox: { + enabled: boolean; + pollIntervalSec: number; + archiveOnProcess?: boolean; + }; + webhook: { + enabled: boolean; + port: number; + path: string; + healthPath?: string; + authBearerEnv?: string; + webhookSecretEnv?: string; + webhookSignatureHeader?: string; + }; + p2pRail: { + enabled: boolean; + baseUrlEnv: string; + pollIntervalSec: number; + transactionsPath: string; + }; + finGateway: { + enabled: boolean; + baseUrlEnv: string; + fallbackBaseUrlEnv?: string; + pollIntervalSec: number; + messagesPath: string; + authBearerEnv?: string; + }; + swiftFinTcp: { + enabled: boolean; + host: string; + portEnv: string; + defaultPort: number; + }; + as4Inbox: { + enabled: boolean; + pollIntervalSec: number; + archiveOnProcess?: boolean; + }; + mailAttachmentInbox: { + enabled: boolean; + pollIntervalSec: number; + archiveOnProcess?: boolean; + }; + }; + outbound?: { + omnlForward: { + enabled: boolean; + mode: 'local' | 'api' | 'local_and_api'; + apiPath?: string; + tokenAggregationUrlEnv: string; + apiKeyEnv: string; + localStoreDir: string; + }; + intakeGateway: { + enabled: boolean; + gatewayAddressEnv: string; + gatewayAddressFallback: string; + rpcUrlEnv: string; + privateKeyEnv: string; + executeEnv: string; + dryRunDefault: boolean; + autoExecuteWhenKeyPresent?: boolean; + chainId: number; + }; + }; + /** @deprecated use outbound.omnlForward */ + omnlForward?: { + enabled: boolean; + tokenAggregationUrlEnv: string; + apiKeyEnv: string; + }; +}; +export type ResourceRegistry = { + chainId: number; + chainNames: string[]; + symbols: Set; + cwSymbols: Set; + addresses: Map; + instructionPrefixes: string[]; + remittanceKeywords: string[]; + contracts: string[]; +}; +export type SwiftListenerOptions = { + /** Process one batch and return (for --once). */ + once?: boolean; +}; +export type InboundMessage = { + source: string; + format: InboundFormat; + payload: string; + metadata?: Record; +}; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/services/swift-listener/dist/types.d.ts.map b/services/swift-listener/dist/types.d.ts.map new file mode 100644 index 0000000..ed144fc --- /dev/null +++ b/services/swift-listener/dist/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/services/swift-listener/dist/types.js b/services/swift-listener/dist/types.js new file mode 100644 index 0000000..11e638d --- /dev/null +++ b/services/swift-listener/dist/types.js @@ -0,0 +1,3 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/services/swift-listener/dist/types.js.map b/services/swift-listener/dist/types.js.map new file mode 100644 index 0000000..c768b79 --- /dev/null +++ b/services/swift-listener/dist/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/services/swift-listener/package-lock.json b/services/swift-listener/package-lock.json new file mode 100644 index 0000000..590e460 --- /dev/null +++ b/services/swift-listener/package-lock.json @@ -0,0 +1,364 @@ +{ + "name": "swift-listener-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swift-listener-service", + "version": "1.0.0", + "dependencies": { + "@dbis/checkpoint-core": "file:../../packages/checkpoint-core", + "@dbis/integration-foundation": "file:../../packages/integration-foundation", + "ethers": "^6.16.0" + }, + "devDependencies": { + "@types/node": "^20.19.33", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + } + }, + "../../../node_modules/.pnpm/@types+node@20.19.33/node_modules/@types/node": { + "version": "20.19.33", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "../../../node_modules/.pnpm/ethers@6.16.0_bufferutil@4.1.0_utf-8-validate@5.0.10/node_modules/ethers": { + "version": "6.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "15.3.0", + "@types/mocha": "10.0.9", + "@types/semver": "7.5.8", + "c8": "7.12.0", + "mocha": "10.7.3", + "rollup": "4.24.0", + "semver": "7.6.3", + "typescript": "5.0.4", + "uglify-js": "3.19.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "../../../node_modules/.pnpm/ts-node@10.9.2_@types+node@20.19.33_typescript@5.9.3/node_modules/ts-node": { + "version": "10.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.19.4", + "@swc/core": "^1.3.100", + "@swc/wasm": "^1.3.100", + "@types/diff": "^4.0.2", + "@types/lodash": "^4.14.151", + "@types/node": "13.13.5", + "@types/proper-lockfile": "^4.1.2", + "@types/proxyquire": "^1.3.28", + "@types/react": "^16.14.19", + "@types/rimraf": "^3.0.0", + "@types/semver": "^7.1.0", + "@yarnpkg/fslib": "^2.4.0", + "ava": "^3.15.0", + "axios": "^0.21.1", + "dprint": "^0.25.0", + "expect": "^27.0.2", + "get-stream": "^6.0.0", + "lodash": "^4.17.15", + "ntypescript": "^1.201507091536.1", + "nyc": "^15.0.1", + "outdent": "^0.8.0", + "proper-lockfile": "^4.1.2", + "proxyquire": "^2.0.0", + "react": "^16.14.0", + "rimraf": "^3.0.0", + "semver": "^7.1.3", + "throat": "^6.0.1", + "typedoc": "^0.22.10", + "typescript": "4.7.4", + "typescript-json-schema": "^0.53.0", + "util.promisify": "^1.0.1" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "devDependencies": { + "@dprint/formatter": "^0.4.1", + "@dprint/typescript": "0.93.4", + "@esfx/canceltoken": "^1.0.0", + "@eslint/js": "^9.20.0", + "@octokit/rest": "^21.1.1", + "@types/chai": "^4.3.20", + "@types/diff": "^7.0.1", + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.10", + "@types/ms": "^0.7.34", + "@types/node": "latest", + "@types/source-map-support": "^0.5.10", + "@types/which": "^3.0.4", + "@typescript-eslint/rule-tester": "^8.24.1", + "@typescript-eslint/type-utils": "^8.24.1", + "@typescript-eslint/utils": "^8.24.1", + "azure-devops-node-api": "^14.1.0", + "c8": "^10.1.3", + "chai": "^4.5.0", + "chokidar": "^4.0.3", + "diff": "^7.0.0", + "dprint": "^0.49.0", + "esbuild": "^0.25.0", + "eslint": "^9.20.1", + "eslint-formatter-autolinkable-stylish": "^1.4.0", + "eslint-plugin-regexp": "^2.7.0", + "fast-xml-parser": "^4.5.2", + "glob": "^10.4.5", + "globals": "^15.15.0", + "hereby": "^1.10.0", + "jsonc-parser": "^3.3.1", + "knip": "^5.44.4", + "minimist": "^1.2.8", + "mocha": "^10.8.2", + "mocha-fivemat-progress-reporter": "^0.1.0", + "monocart-coverage-reports": "^2.12.1", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "playwright": "^1.50.1", + "source-map-support": "^0.5.21", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "typescript-eslint": "^8.24.1", + "which": "^3.0.1" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.33_@vitest+ui@1.6.1_jsdom@27.4.0_@noble+hashes@1.8.0_buf_33ed9ba4035bceb14c04e4f593278405/node_modules/vitest": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "devDependencies": { + "@ampproject/remapping": "^2.2.1", + "@antfu/install-pkg": "^0.3.1", + "@edge-runtime/vm": "^3.1.8", + "@sinonjs/fake-timers": "11.1.0", + "@types/estree": "^1.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/jsdom": "^21.1.6", + "@types/micromatch": "^4.0.6", + "@types/node": "^20.11.5", + "@types/prompts": "^2.4.9", + "@types/sinonjs__fake-timers": "^8.1.5", + "birpc": "0.2.15", + "cac": "^6.7.14", + "chai-subset": "^1.6.0", + "cli-truncate": "^4.0.0", + "expect-type": "^0.17.3", + "fast-glob": "^3.3.2", + "find-up": "^6.3.0", + "flatted": "^3.2.9", + "get-tsconfig": "^4.7.3", + "happy-dom": "^14.3.10", + "jsdom": "^24.0.0", + "log-update": "^5.0.1", + "micromatch": "^4.0.5", + "p-limit": "^5.0.0", + "pretty-format": "^29.7.0", + "prompts": "^2.4.2", + "strip-ansi": "^7.1.0", + "ws": "^8.14.2" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "../../packages/checkpoint-core": { + "name": "@dbis/checkpoint-core", + "version": "0.1.0", + "dependencies": { + "ethers": "^6.13.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } + }, + "../../packages/checkpoint-core/node_modules/@types/node": { + "resolved": "../../../node_modules/.pnpm/@types+node@20.19.33/node_modules/@types/node", + "link": true + }, + "../../packages/checkpoint-core/node_modules/ethers": { + "resolved": "../../../node_modules/.pnpm/ethers@6.16.0_bufferutil@4.1.0_utf-8-validate@5.0.10/node_modules/ethers", + "link": true + }, + "../../packages/checkpoint-core/node_modules/typescript": { + "resolved": "../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript", + "link": true + }, + "../../packages/integration-foundation": { + "name": "@dbis/integration-foundation", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + } + }, + "../../packages/integration-foundation/node_modules/@types/node": { + "resolved": "../../../node_modules/.pnpm/@types+node@20.19.33/node_modules/@types/node", + "link": true + }, + "../../packages/integration-foundation/node_modules/typescript": { + "resolved": "../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript", + "link": true + }, + "node_modules/@dbis/checkpoint-core": { + "resolved": "../../packages/checkpoint-core", + "link": true + }, + "node_modules/@dbis/integration-foundation": { + "resolved": "../../packages/integration-foundation", + "link": true + }, + "node_modules/@types/node": { + "resolved": "../../../node_modules/.pnpm/@types+node@20.19.33/node_modules/@types/node", + "link": true + }, + "node_modules/ethers": { + "resolved": "../../../node_modules/.pnpm/ethers@6.16.0_bufferutil@4.1.0_utf-8-validate@5.0.10/node_modules/ethers", + "link": true + }, + "node_modules/ts-node": { + "resolved": "../../../node_modules/.pnpm/ts-node@10.9.2_@types+node@20.19.33_typescript@5.9.3/node_modules/ts-node", + "link": true + }, + "node_modules/typescript": { + "resolved": "../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript", + "link": true + }, + "node_modules/vitest": { + "resolved": "../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.33_@vitest+ui@1.6.1_jsdom@27.4.0_@noble+hashes@1.8.0_buf_33ed9ba4035bceb14c04e4f593278405/node_modules/vitest", + "link": true + } + } +} diff --git a/services/swift-listener/package.json b/services/swift-listener/package.json new file mode 100644 index 0000000..181b35d --- /dev/null +++ b/services/swift-listener/package.json @@ -0,0 +1,24 @@ +{ + "name": "swift-listener-service", + "version": "1.0.0", + "description": "SWIFT FIN / ISO 20022 listener for Chain 138 and cW* asset broadcasts", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/cli.js", + "dev": "ts-node src/cli.ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@dbis/checkpoint-core": "file:../../packages/checkpoint-core", + "@dbis/integration-foundation": "file:../../packages/integration-foundation", + "ethers": "^6.16.0" + }, + "devDependencies": { + "@types/node": "^20.19.33", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vitest": "^1.6.1" + } +} diff --git a/services/swift-listener/src/adapters.test.ts b/services/swift-listener/src/adapters.test.ts new file mode 100644 index 0000000..fdb73dd --- /dev/null +++ b/services/swift-listener/src/adapters.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { extractFinMessages } from '../src/adapters/swiftFinTcpAdapter'; +import { forwardToOmnl } from '../src/outbound/omnlForwardAdapter'; +import { buildGatewayMessage, submitToIntakeGateway } from '../src/outbound/intakeGatewayAdapter'; +import { loadListenerConfig, buildResourceRegistry } from '../src/resourceRegistry'; +import { mapToCanonical } from '../src/canonical/mapToCanonical'; +import { parseSwiftFin } from '../src/parsers/inboundParser'; +import type { SwiftListenerEvent } from '../src/types'; + +const ROOT = resolve(__dirname, '../../../..'); + +describe('swiftFinTcpAdapter', () => { + it('extracts brace-delimited FIN messages from stream buffer', () => { + const chunk = `{1:F01TEST}{2:I103TEST}{4::20:REF-1-}`; + const { messages, remainder } = extractFinMessages(chunk); + expect(messages.length).toBe(1); + expect(messages[0]).toContain(':20:REF-1'); + expect(remainder).toBe(''); + }); +}); + +describe('outbound adapters', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'swift-listener-')); + process.env.OMNL_ISO20022_STORE_DIR = join(tmp, 'omnl'); + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + }); + + afterEach(() => { + delete process.env.OMNL_ISO20022_STORE_DIR; + delete process.env.SWIFT_LISTENER_GATEWAY_EXECUTE; + rmSync(tmp, { recursive: true, force: true }); + }); + + it('forwards matched event to local OMNL store', async () => { + const config = loadListenerConfig(ROOT); + config.outbound = { + omnlForward: { + enabled: true, + mode: 'local', + tokenAggregationUrlEnv: 'TOKEN_AGGREGATION_URL', + apiKeyEnv: 'OMNL_API_KEY', + localStoreDir: 'config/iso20022-omnl/messages', + }, + intakeGateway: config.outbound?.intakeGateway ?? { + enabled: false, + gatewayAddressEnv: 'ISO20022_INTAKE_GATEWAY_MAINNET', + gatewayAddressFallback: '0x66C89F91A4e830d87fcE4E5F86aFA71A96FbDa3d', + rpcUrlEnv: 'RPC_URL_138', + privateKeyEnv: 'PRIVATE_KEY', + executeEnv: 'SWIFT_LISTENER_GATEWAY_EXECUTE', + dryRunDefault: true, + chainId: 138, + }, + }; + + const parsed = parseSwiftFin(`{1:F01BANK}{2:I103BANK}{4::20:CHAIN138-x:70:cWUSDC-}`); + const canonical = mapToCanonical(parsed, 'abc123'); + const event: SwiftListenerEvent = { + id: 'evt-1', + receivedAt: new Date().toISOString(), + source: 'test', + format: 'swift_fin', + messageType: 'MT103', + matched: true, + matches: [], + parsed: parsed.parsed, + canonical, + rawPreview: 'preview', + dedupeKey: 'abc123', + }; + + const result = await forwardToOmnl(ROOT, config, event); + expect(result.stored).toBeTruthy(); + const files = readdirSync(process.env.OMNL_ISO20022_STORE_DIR!); + expect(files.length).toBe(1); + const stored = JSON.parse(readFileSync(join(process.env.OMNL_ISO20022_STORE_DIR!, files[0]!), 'utf8')); + expect(stored.instructionId).toContain('CHAIN138'); + }); + + it('builds intake gateway message and dry-runs by default', async () => { + const config = loadListenerConfig(ROOT); + const registry = buildResourceRegistry(ROOT, config); + config.outbound = { + omnlForward: config.outbound?.omnlForward ?? { + enabled: false, + mode: 'local', + tokenAggregationUrlEnv: 'TOKEN_AGGREGATION_URL', + apiKeyEnv: 'OMNL_API_KEY', + localStoreDir: 'config/iso20022-omnl/messages', + }, + intakeGateway: { + enabled: true, + gatewayAddressEnv: 'ISO20022_INTAKE_GATEWAY_MAINNET', + gatewayAddressFallback: '0x66C89F91A4e830d87fcE4E5F86aFA71A96FbDa3d', + rpcUrlEnv: 'RPC_URL_138', + privateKeyEnv: 'PRIVATE_KEY', + executeEnv: 'SWIFT_LISTENER_GATEWAY_EXECUTE', + dryRunDefault: true, + chainId: 138, + }, + }; + + const parsed = parseSwiftFin(`{1:F01BANK}{2:I103BANK}{4::20:CHAIN138-x:32A:230101USD100,00-}`); + const canonical = mapToCanonical(parsed, 'dedupe')!; + const msg = buildGatewayMessage(canonical, registry); + expect(msg.instructionId).toMatch(/^0x/); + expect(msg.currencyCode).toMatch(/^0x/); + + const submit = await submitToIntakeGateway(config, registry, canonical); + expect(submit.dryRun).toBe(true); + expect(submit.prepared).toBe(true); + expect(submit.gateway).toBeTruthy(); + }); +}); diff --git a/services/swift-listener/src/adapters/inboundAdapters.ts b/services/swift-listener/src/adapters/inboundAdapters.ts new file mode 100644 index 0000000..716b79f --- /dev/null +++ b/services/swift-listener/src/adapters/inboundAdapters.ts @@ -0,0 +1,142 @@ +import { readdirSync, readFileSync, statSync, renameSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import type { InboundMessage, InboundFormat } from '../types'; +import { detectInboundFormat } from '../parsers/inboundParser'; + +const INBOX_EXTENSIONS = /\.(fin|swift|xml|txt|json|as4|mx)$/i; + +export function scanDirectoryInbox( + inboxDir: string, + sourcePrefix: string, + defaultFormat?: InboundFormat +): InboundMessage[] { + const messages: InboundMessage[] = []; + let entries: string[] = []; + try { + entries = readdirSync(inboxDir); + } catch { + return messages; + } + + for (const name of entries.sort()) { + const full = join(inboxDir, name); + if (!statSync(full).isFile()) continue; + if (name.startsWith('.')) continue; + if (!INBOX_EXTENSIONS.test(name)) continue; + const payload = readFileSync(full, 'utf8'); + const lower = name.toLowerCase(); + let format: InboundFormat = defaultFormat ?? detectInboundFormat(payload); + if (lower.endsWith('.xml') || lower.endsWith('.mx') || lower.endsWith('.as4')) format = 'iso20022'; + if (lower.endsWith('.json')) format = 'json_broadcast'; + messages.push({ + source: `${sourcePrefix}:${full}`, + format, + payload, + metadata: { fileName: name, filePath: full }, + }); + } + return messages; +} + +export function archiveInboxFile( + filePath: string, + archiveDir: string +): void { + mkdirSync(archiveDir, { recursive: true }); + const base = filePath.split('/').pop() ?? 'message'; + renameSync(filePath, join(archiveDir, `${Date.now()}-${base}`)); +} + +export function scanFileInbox(inboxDir: string): InboundMessage[] { + return scanDirectoryInbox(inboxDir, 'file'); +} + +export async function pollP2pRailTransactions(options: { + baseUrl: string; + bearerToken?: string; + apiKey?: string; + path?: string; + sinceId?: string; +}): Promise { + const url = `${options.baseUrl.replace(/\/$/, '')}${options.path ?? '/api/transactions'}`; + const headers: Record = { Accept: 'application/json' }; + if (options.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`; + if (options.apiKey) headers['X-API-Key'] = options.apiKey; + + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`P2P rail poll failed: ${res.status} ${res.statusText}`); + } + + const body = (await res.json()) as unknown; + const rows = Array.isArray(body) ? body : (body as { data?: unknown[] })?.data ?? []; + const out: InboundMessage[] = []; + + for (const row of rows) { + const obj = row as Record; + const id = String(obj.id ?? obj.transactionId ?? obj.reference ?? ''); + if (options.sinceId && id && id <= options.sinceId) continue; + const payload = + typeof obj.payload === 'string' + ? obj.payload + : typeof obj.swiftMessage === 'string' + ? obj.swiftMessage + : typeof obj.message === 'string' + ? obj.message + : JSON.stringify(obj); + out.push({ + source: `p2p:${id || 'unknown'}`, + format: detectInboundFormat(payload), + payload, + metadata: obj, + }); + } + return out; +} + +export async function pollFinGatewayMessages(options: { + baseUrl: string; + bearerToken?: string; + path?: string; + sinceId?: string; +}): Promise { + const url = `${options.baseUrl.replace(/\/$/, '')}${options.path ?? '/api/messages'}`; + const headers: Record = { Accept: 'application/json' }; + if (options.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`; + + const res = await fetch(url, { headers }); + if (!res.ok) { + throw new Error(`FIN gateway poll failed: ${res.status} ${res.statusText}`); + } + + const body = (await res.json()) as unknown; + const rows = Array.isArray(body) + ? body + : (body as { messages?: unknown[]; data?: unknown[] })?.messages ?? + (body as { data?: unknown[] })?.data ?? + []; + + const out: InboundMessage[] = []; + for (const row of rows) { + const obj = row as Record; + const id = String(obj.id ?? obj.messageId ?? obj.reference ?? ''); + if (options.sinceId && id && id <= options.sinceId) continue; + const payload = + typeof obj.finMessage === 'string' + ? obj.finMessage + : typeof obj.swiftMessage === 'string' + ? obj.swiftMessage + : typeof obj.payload === 'string' + ? obj.payload + : typeof obj.body === 'string' + ? obj.body + : JSON.stringify(obj); + out.push({ + source: `fin:${id || 'unknown'}`, + format: detectInboundFormat(payload), + payload, + metadata: obj, + }); + } + return out; +} diff --git a/services/swift-listener/src/adapters/swiftFinTcpAdapter.ts b/services/swift-listener/src/adapters/swiftFinTcpAdapter.ts new file mode 100644 index 0000000..58321db --- /dev/null +++ b/services/swift-listener/src/adapters/swiftFinTcpAdapter.ts @@ -0,0 +1,72 @@ +import net, { type Server } from 'net'; +import type { InboundMessage } from '../types'; +import { detectInboundFormat } from '../parsers/inboundParser'; + +/** Extract FIN messages from a TCP buffer (brace blocks or $...$ framed). */ +export function extractFinMessages(buffer: string): { messages: string[]; remainder: string } { + const messages: string[] = []; + let rest = buffer; + + while (rest.length > 0) { + const dollarStart = rest.indexOf('$'); + const braceStart = rest.indexOf('{1:'); + + if (braceStart >= 0 && (dollarStart < 0 || braceStart < dollarStart)) { + const end = rest.indexOf('-}', braceStart); + if (end < 0) break; + messages.push(rest.slice(braceStart, end + 2)); + rest = rest.slice(end + 2); + continue; + } + + if (dollarStart >= 0) { + const end = rest.indexOf('$', dollarStart + 1); + if (end < 0) break; + messages.push(rest.slice(dollarStart + 1, end)); + rest = rest.slice(end + 1); + continue; + } + + break; + } + + return { messages, remainder: rest }; +} + +export type SwiftFinTcpServerOptions = { + host: string; + port: number; + onMessage: (msg: InboundMessage) => void; +}; + +export function startSwiftFinTcpServer(options: SwiftFinTcpServerOptions): Server { + const buffers = new Map(); + + const server = net.createServer((socket) => { + const key = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`; + buffers.set(key, ''); + + socket.on('data', (chunk) => { + const prev = buffers.get(key) ?? ''; + const combined = prev + chunk.toString('utf8'); + const { messages, remainder } = extractFinMessages(combined); + buffers.set(key, remainder); + + for (const payload of messages) { + if (!payload.trim()) continue; + options.onMessage({ + source: `tcp:${key}:${Date.now()}`, + format: detectInboundFormat(payload), + payload, + metadata: { remote: key }, + }); + } + }); + + socket.on('close', () => buffers.delete(key)); + socket.on('error', () => buffers.delete(key)); + }); + + server.listen(options.port, options.host); + return server; +} diff --git a/services/swift-listener/src/adapters/webhookServer.ts b/services/swift-listener/src/adapters/webhookServer.ts new file mode 100644 index 0000000..059634c --- /dev/null +++ b/services/swift-listener/src/adapters/webhookServer.ts @@ -0,0 +1,139 @@ +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'; +import { URL } from 'url'; +import { + verifyWebhookSignature, + deriveIdempotencyKey, + IdempotencyStore, + createCorrelationContext, + CORRELATION_ID_HEADER, + REQUEST_ID_HEADER, +} from '@dbis/integration-foundation'; +import type { InboundMessage } from '../types'; +import { detectInboundFormat } from '../parsers/inboundParser'; + +export type WebhookServerOptions = { + port: number; + path: string; + healthPath?: string; + authBearer?: string; + webhookSecret?: string; + webhookSignatureHeader?: string; + onMessage: (msg: InboundMessage) => Promise; +}; + +const idempotencyStore = new IdempotencyStore(); + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function unauthorized(res: ServerResponse, reason = 'unauthorized'): void { + res.statusCode = 401; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: reason })); +} + +function conflict(res: ServerResponse): void { + res.statusCode = 409; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'duplicate idempotency key' })); +} + +export function createWebhookServer(options: WebhookServerOptions): Server { + const healthPath = options.healthPath ?? '/health'; + const sigHeader = options.webhookSignatureHeader ?? 'x-hybx-signature'; + + return createServer((req, res) => { + void (async () => { + try { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + if (req.method === 'GET' && url.pathname === healthPath) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'ok', service: 'swift-listener' })); + return; + } + + if (url.pathname !== options.path) { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'not found' })); + return; + } + + if (req.method !== 'POST') { + res.statusCode = 405; + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + + const raw = await readBody(req); + + if (options.webhookSecret) { + const sig = String(req.headers[sigHeader] ?? req.headers['x-webhook-signature'] ?? ''); + if (!verifyWebhookSignature({ secret: options.webhookSecret, payload: raw, signature: sig })) { + unauthorized(res, 'invalid webhook signature'); + return; + } + } else if (options.authBearer) { + const auth = String(req.headers.authorization ?? ''); + const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (token !== options.authBearer) { + unauthorized(res); + return; + } + } + + const idempotencyHeader = String(req.headers['x-idempotency-key'] ?? ''); + if (idempotencyHeader) { + const key = deriveIdempotencyKey({ + tenantId: 'swift-listener', + entityId: 'webhook', + operation: 'inbound', + resourceId: idempotencyHeader, + }); + if (!idempotencyStore.record(key)) { + conflict(res); + return; + } + } + + const correlation = createCorrelationContext({ + correlationId: String(req.headers[CORRELATION_ID_HEADER] ?? ''), + requestId: String(req.headers[REQUEST_ID_HEADER] ?? req.headers['x-request-id'] ?? ''), + }); + + const contentType = String(req.headers['content-type'] ?? ''); + let format = detectInboundFormat(raw); + if (contentType.includes('xml')) format = 'iso20022'; + if (contentType.includes('json') && format !== 'swift_fin') format = 'json_broadcast'; + + await options.onMessage({ + source: `webhook:${correlation.requestId}`, + format, + payload: raw, + metadata: { + contentType, + path: url.pathname, + correlationId: correlation.correlationId, + requestId: correlation.requestId, + }, + }); + + res.statusCode = 202; + res.setHeader('Content-Type', 'application/json'); + res.setHeader(CORRELATION_ID_HEADER, correlation.correlationId); + res.setHeader(REQUEST_ID_HEADER, correlation.requestId); + res.end(JSON.stringify({ accepted: true, correlationId: correlation.correlationId })); + } catch (e) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); + } + })(); + }).listen(options.port); +} diff --git a/services/swift-listener/src/canonical/mapToCanonical.ts b/services/swift-listener/src/canonical/mapToCanonical.ts new file mode 100644 index 0000000..857ca8d --- /dev/null +++ b/services/swift-listener/src/canonical/mapToCanonical.ts @@ -0,0 +1,85 @@ +import { ethers } from 'ethers'; +import { MSG_TYPE, hashShortUtf8, type CanonicalPaymentMessage } from '@dbis/checkpoint-core'; +import type { ParsedInbound } from '../types'; + +function msgTypeCode(parsed: ParsedInbound): CanonicalPaymentMessage['msgTypeCode'] { + if (parsed.format === 'swift_fin') { + if (parsed.messageType === 'MT103') return MSG_TYPE.MT103; + return MSG_TYPE.UNKNOWN; + } + if (parsed.format === 'iso20022') { + if (parsed.messageType === 'pacs.008') return MSG_TYPE.PACS008; + if (parsed.messageType === 'pain.001') return MSG_TYPE.PAIN001; + } + return MSG_TYPE.UNKNOWN; +} + +function msgTypeLabel(parsed: ParsedInbound): CanonicalPaymentMessage['msgType'] { + if (parsed.format === 'swift_fin' && parsed.messageType === 'MT103') return 'MT103'; + if (parsed.format === 'iso20022' && parsed.messageType === 'pacs.008') return 'pacs.008'; + if (parsed.format === 'iso20022' && parsed.messageType === 'pain.001') return 'pain.001'; + return 'chain138.synthetic'; +} + +function pickString(parsed: ParsedInbound, ...keys: string[]): string { + const obj = parsed.parsed; + for (const k of keys) { + const v = obj[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + return ''; +} + +export function mapToCanonical(parsed: ParsedInbound, dedupeKey: string): CanonicalPaymentMessage | undefined { + const instructionId = + pickString(parsed, 'instructionId', 'senderReference', 'transactionReference', 'messageId') || + `SWIFT-${dedupeKey.slice(0, 16)}`; + const endToEndId = pickString(parsed, 'endToEndId', 'transactionReference', 'senderReference') || instructionId; + const msgId = pickString(parsed, 'messageId', 'senderReference') || instructionId; + const uetr = pickString(parsed, 'uetr') || ''; + const debtorId = pickString(parsed, 'orderingCustomer', 'debtor', 'sendingInstitution') || 'UNKNOWN'; + const creditorId = pickString(parsed, 'beneficiaryCustomer', 'creditor', 'beneficiaryInstitution') || 'UNKNOWN'; + const currencyCode = pickString(parsed, 'currency') || 'USD'; + const amountRaw = pickString(parsed, 'amount') || '0'; + const purpose = + pickString(parsed, 'remittanceInfo', 'remittance', 'purpose', 'senderToReceiverInfo', 'narrative') || + `${parsed.format}/${parsed.messageType}`; + + const instructionIdBytes32 = ethers.keccak256(ethers.toUtf8Bytes(`INSTR:${instructionId}`)); + const uetrBytes32 = uetr + ? ethers.keccak256(ethers.toUtf8Bytes(`UETR:${uetr}`)) + : ethers.keccak256(ethers.toUtf8Bytes(`UETR:${dedupeKey}`)); + + const base: Omit = { + msgType: msgTypeLabel(parsed), + msgTypeCode: msgTypeCode(parsed), + instructionId, + instructionIdBytes32, + endToEndId, + endToEndIdHash: hashShortUtf8(endToEndId), + msgId, + uetr: uetr || dedupeKey.slice(0, 36), + uetrBytes32, + accountRefId: debtorId, + counterpartyRefId: creditorId, + debtorId, + creditorId, + purpose, + settlementMethod: parsed.format === 'swift_fin' ? 'SWIFT' : 'ISO20022', + categoryPurpose: 'CBFF', + currencyCode, + amountRaw, + amountSmallestUnit: amountRaw.replace(/[,.]/g, '') || '0', + debtorRefHash: hashShortUtf8(debtorId), + creditorRefHash: hashShortUtf8(creditorId), + purposeHash: hashShortUtf8(purpose), + chain138TxHash: pickString(parsed, 'txId', 'chain138TxHash'), + valueDateIso: pickString(parsed, 'valueDate') || undefined, + }; + + const payloadHash = ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify({ ...base, sourceFormat: parsed.format, messageType: parsed.messageType })) + ); + + return { ...base, payloadHash }; +} diff --git a/services/swift-listener/src/cli.ts b/services/swift-listener/src/cli.ts new file mode 100644 index 0000000..83f1aba --- /dev/null +++ b/services/swift-listener/src/cli.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import { resolve } from 'path'; +import { mkdirSync } from 'fs'; +import { createSwiftListener } from './listener'; +import { loadListenerConfig } from './resourceRegistry'; + +function parseArgs(argv: string[]) { + let once = false; + let projectRoot = process.env.PROXMOX_ROOT ?? process.cwd(); + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--once') once = true; + else if (a === '--project-root') projectRoot = argv[++i] ?? projectRoot; + else if (a.startsWith('--project-root=')) projectRoot = a.slice('--project-root='.length); + else if (a === '--help' || a === '-h') { + console.log(`Usage: swift-listener [--once] [--project-root PATH] + +Inbound adapters (env flags): + SWIFT_LISTENER_FILE_INBOX=1|0 file drop inbox (default on) + SWIFT_LISTENER_WEBHOOK=1 HTTP POST /swift/inbound + GET /health + SWIFT_LISTENER_P2P_RAIL=1 banktransfer Financial Broadcast poll + SWIFT_LISTENER_FIN_GATEWAY=1 FIN / Alliance Access HTTP poll + SWIFT_LISTENER_FIN_TCP=1 SWIFT FIN copy TCP listener + SWIFT_LISTENER_AS4_INBOX=1 AS4 / ISO drop directory poll + SWIFT_LISTENER_MAIL_INBOX=1 mail attachment drop directory poll + +Outbound (on match): + SWIFT_LISTENER_OMNL_FORWARD=1 local ISO store + token-aggregation API + SWIFT_LISTENER_INTAKE_GATEWAY=1 ISO20022IntakeGateway (dry-run unless EXECUTE=1) + SWIFT_LISTENER_GATEWAY_EXECUTE=1 broadcast submitInbound tx + +Credentials: + P2P_BASE_URL / P2P_BEARER_TOKEN / P2P_API_KEY + FIN_GATEWAY_URL / ALLIANCE_ACCESS_URL / FIN_GATEWAY_TOKEN + SWIFT_FIN_TCP_PORT (default 5001) + SWIFT_LISTENER_WEBHOOK_TOKEN + RPC_URL_138 / PRIVATE_KEY / ISO20022_INTAKE_GATEWAY_MAINNET + TOKEN_AGGREGATION_URL / OMNL_API_KEY +`); + process.exit(0); + } + } + return { once, projectRoot: resolve(projectRoot) }; +} + +async function main() { + const { once, projectRoot } = parseArgs(process.argv); + const config = loadListenerConfig(projectRoot); + + const dirs = [ + config.storage.inboxDir, + config.storage.eventsDir, + config.storage.as4InboxDir, + config.storage.mailInboxDir, + ].filter(Boolean) as string[]; + + for (const d of dirs) { + mkdirSync(resolve(projectRoot, d), { recursive: true }); + } + + const listener = createSwiftListener(projectRoot); + await listener.run({ once }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/services/swift-listener/src/index.ts b/services/swift-listener/src/index.ts new file mode 100644 index 0000000..d248725 --- /dev/null +++ b/services/swift-listener/src/index.ts @@ -0,0 +1,11 @@ +export * from './types'; +export { loadListenerConfig, buildResourceRegistry, applyEnvOverrides } from './resourceRegistry'; +export { parseInbound, parseSwiftFin, parseIso20022 } from './parsers/inboundParser'; +export { matchResources } from './matchers/resourceMatcher'; +export { mapToCanonical } from './canonical/mapToCanonical'; +export { createSwiftListener, SwiftListener } from './listener'; +export { extractFinMessages, startSwiftFinTcpServer } from './adapters/swiftFinTcpAdapter'; +export { createWebhookServer } from './adapters/webhookServer'; +export { forwardToOmnl, OMNL_ISO20022_API_PATH } from './outbound/omnlForwardAdapter'; +export { buildGatewayMessage, submitToIntakeGateway } from './outbound/intakeGatewayAdapter'; +export { dispatchMatchedOutbounds } from './outbound/dispatchOutcomes'; diff --git a/services/swift-listener/src/listener.ts b/services/swift-listener/src/listener.ts new file mode 100644 index 0000000..a8bf759 --- /dev/null +++ b/services/swift-listener/src/listener.ts @@ -0,0 +1,329 @@ +import { resolve } from 'path'; +import type { Server } from 'http'; +import type net from 'net'; +import { matchResources, currencyMatchesCwAsset } from './matchers/resourceMatcher'; +import { parseInbound } from './parsers/inboundParser'; +import { mapToCanonical } from './canonical/mapToCanonical'; +import { buildResourceRegistry, loadListenerConfig } from './resourceRegistry'; +import { buildEvent, EventStore } from './store/eventStore'; +import { + archiveInboxFile, + pollFinGatewayMessages, + pollP2pRailTransactions, + scanDirectoryInbox, + scanFileInbox, +} from './adapters/inboundAdapters'; +import { createWebhookServer } from './adapters/webhookServer'; +import { startSwiftFinTcpServer } from './adapters/swiftFinTcpAdapter'; +import { dispatchMatchedOutbounds } from './outbound/dispatchOutcomes'; +import type { InboundMessage, SwiftListenerEvent, SwiftListenerOptions } from './types'; + +export type { SwiftListenerOptions }; + +export class SwiftListener { + private readonly config; + private readonly registry; + private readonly store: EventStore; + private lastP2pId?: string; + private lastFinId?: string; + private running = false; + private webhookServer?: Server; + private tcpServer?: net.Server; + + constructor(private readonly projectRoot: string) { + this.config = loadListenerConfig(this.projectRoot); + this.registry = buildResourceRegistry(this.projectRoot, this.config); + this.store = new EventStore(this.projectRoot, this.config); + } + + async processMessage(msg: InboundMessage): Promise { + const dedupeKey = this.store.dedupeKey(msg.source, msg.payload); + if (this.store.isProcessed(dedupeKey)) return null; + + const parsed = parseInbound(msg.payload); + let matches = matchResources(this.registry, parsed); + + const currency = + typeof parsed.parsed.currency === 'string' ? parsed.parsed.currency : undefined; + if (currencyMatchesCwAsset(currency, this.registry)) { + matches = [ + ...matches, + { + kind: 'cw_symbol', + value: currency!.toUpperCase(), + symbol: currency!.toUpperCase(), + field: 'parsed.currency', + }, + ]; + } + + const canonical = matches.length > 0 ? mapToCanonical(parsed, dedupeKey) : undefined; + let event = buildEvent( + msg.source, + parsed.parsed, + parsed.format, + String(parsed.messageType), + matches, + canonical, + msg.payload, + dedupeKey + ); + + if (event.matched && event.canonical) { + event = { + ...event, + outbound: await dispatchMatchedOutbounds( + this.projectRoot, + this.config, + this.registry, + event + ), + }; + console.log( + JSON.stringify({ + wired: 'match-outbound', + eventId: event.id, + omnlApi: event.outbound?.omnl?.apiPath, + omnlStatus: event.outbound?.omnl?.apiStatus, + gateway: event.outbound?.intakeGateway?.gateway, + gatewayDryRun: event.outbound?.intakeGateway?.dryRun, + gatewayTx: event.outbound?.intakeGateway?.txHash, + }) + ); + } + + this.store.saveEvent(event); + this.maybeArchiveSource(msg); + return event; + } + + private maybeArchiveSource(msg: InboundMessage): void { + const filePath = msg.metadata?.filePath; + if (typeof filePath !== 'string') return; + + const archiveDir = resolve(this.projectRoot, this.config.storage.archiveDir); + if (msg.source.startsWith('file:') && this.config.adapters.fileInbox.archiveOnProcess) { + try { + archiveInboxFile(filePath, archiveDir); + } catch { + // inbox file may already be moved + } + } + if (msg.source.startsWith('as4:') && this.config.adapters.as4Inbox?.archiveOnProcess) { + try { + archiveInboxFile(filePath, resolve(archiveDir, 'as4')); + } catch { + // ignore + } + } + if (msg.source.startsWith('mail:') && this.config.adapters.mailAttachmentInbox?.archiveOnProcess) { + try { + archiveInboxFile(filePath, resolve(archiveDir, 'mail')); + } catch { + // ignore + } + } + } + + async processBatch(sources: InboundMessage[]): Promise { + const out: SwiftListenerEvent[] = []; + for (const msg of sources) { + const ev = await this.processMessage(msg); + if (ev) out.push(ev); + } + return out; + } + + async pollFileInbox(): Promise { + return this.processBatch(scanFileInbox(this.store.inboxPath())); + } + + async pollAs4Inbox(): Promise { + const dir = this.config.storage.as4InboxDir; + if (!dir) return []; + return this.processBatch( + scanDirectoryInbox(resolve(this.projectRoot, dir), 'as4', 'iso20022') + ); + } + + async pollMailInbox(): Promise { + const dir = this.config.storage.mailInboxDir; + if (!dir) return []; + return this.processBatch(scanDirectoryInbox(resolve(this.projectRoot, dir), 'mail')); + } + + async pollP2pRail(): Promise { + const cfg = this.config.adapters.p2pRail; + const baseUrl = process.env[cfg.baseUrlEnv] ?? process.env.P2P_BASE_URL ?? ''; + if (!baseUrl) return []; + const messages = await pollP2pRailTransactions({ + baseUrl, + bearerToken: process.env.P2P_BEARER_TOKEN, + apiKey: process.env.P2P_API_KEY, + path: cfg.transactionsPath, + sinceId: this.lastP2pId, + }); + this.trackLastId(messages, 'p2p'); + return this.processBatch(messages); + } + + async pollFinGateway(): Promise { + const cfg = this.config.adapters.finGateway; + const baseUrl = + process.env[cfg.baseUrlEnv] ?? + (cfg.fallbackBaseUrlEnv ? process.env[cfg.fallbackBaseUrlEnv] : undefined) ?? + process.env.ALLIANCE_ACCESS_URL ?? + ''; + if (!baseUrl) return []; + const tokenEnv = cfg.authBearerEnv ? process.env[cfg.authBearerEnv] : process.env.FIN_GATEWAY_TOKEN; + const messages = await pollFinGatewayMessages({ + baseUrl, + bearerToken: tokenEnv, + path: cfg.messagesPath, + sinceId: this.lastFinId, + }); + this.trackLastId(messages, 'fin'); + return this.processBatch(messages); + } + + private trackLastId(messages: InboundMessage[], prefix: 'p2p' | 'fin'): void { + if (messages.length === 0) return; + const last = messages[messages.length - 1]; + const meta = last.metadata as Record | undefined; + const id = String(meta?.id ?? meta?.transactionId ?? meta?.messageId ?? ''); + if (prefix === 'p2p') this.lastP2pId = id || this.lastP2pId; + else this.lastFinId = id || this.lastFinId; + } + + private startLongRunningAdapters(): void { + const { webhook, swiftFinTcp } = this.config.adapters; + + if (webhook.enabled) { + const authEnv = webhook.authBearerEnv ?? 'SWIFT_LISTENER_WEBHOOK_TOKEN'; + const secretEnv = webhook.webhookSecretEnv ?? 'HYBX_WEBHOOK_SECRET'; + const webhookSecret = process.env[secretEnv]?.trim(); + this.webhookServer = createWebhookServer({ + port: webhook.port, + path: webhook.path, + healthPath: webhook.healthPath, + authBearer: webhookSecret ? undefined : process.env[authEnv], + webhookSecret: webhookSecret || undefined, + webhookSignatureHeader: webhook.webhookSignatureHeader, + onMessage: async (msg) => { + await this.processMessage(msg); + }, + }); + console.log( + JSON.stringify({ + adapter: 'webhook', + port: webhook.port, + path: webhook.path, + healthPath: webhook.healthPath ?? '/health', + }) + ); + } + + if (swiftFinTcp.enabled) { + const port = parseInt( + process.env[swiftFinTcp.portEnv] ?? String(swiftFinTcp.defaultPort), + 10 + ); + this.tcpServer = startSwiftFinTcpServer({ + host: swiftFinTcp.host, + port, + onMessage: (msg) => { + void this.processMessage(msg); + }, + }); + console.log(JSON.stringify({ adapter: 'swiftFinTcp', host: swiftFinTcp.host, port })); + } + } + + private pollIntervals(): number[] { + const { adapters } = this.config; + const intervals: number[] = []; + if (adapters.fileInbox.enabled) intervals.push(adapters.fileInbox.pollIntervalSec); + if (adapters.p2pRail.enabled) intervals.push(adapters.p2pRail.pollIntervalSec); + if (adapters.finGateway?.enabled) intervals.push(adapters.finGateway.pollIntervalSec); + if (adapters.as4Inbox?.enabled) intervals.push(adapters.as4Inbox.pollIntervalSec); + if (adapters.mailAttachmentInbox?.enabled) intervals.push(adapters.mailAttachmentInbox.pollIntervalSec); + return intervals.length ? intervals : [60]; + } + + async runPollCycle(): Promise { + const events: SwiftListenerEvent[] = []; + const { adapters } = this.config; + + if (adapters.fileInbox.enabled) { + events.push(...(await this.pollFileInbox())); + } + if (adapters.as4Inbox?.enabled) { + events.push(...(await this.pollAs4Inbox())); + } + if (adapters.mailAttachmentInbox?.enabled) { + events.push(...(await this.pollMailInbox())); + } + if (adapters.p2pRail.enabled) { + try { + events.push(...(await this.pollP2pRail())); + } catch (e) { + console.error('P2P rail poll error:', e instanceof Error ? e.message : e); + } + } + if (adapters.finGateway?.enabled) { + try { + events.push(...(await this.pollFinGateway())); + } catch (e) { + console.error('FIN gateway poll error:', e instanceof Error ? e.message : e); + } + } + + const matched = events.filter((e) => e.matched); + if (events.length > 0) { + console.log( + JSON.stringify({ + at: new Date().toISOString(), + processed: events.length, + matched: matched.length, + types: [...new Set(events.map((e) => e.messageType))], + outbound: matched.filter((e) => e.outbound).length, + }) + ); + } + return events; + } + + async run(options: SwiftListenerOptions = {}): Promise { + if (this.running) return; + this.running = true; + + this.startLongRunningAdapters(); + await this.runPollCycle(); + + if (options.once) { + this.shutdown(); + return; + } + + const intervalSec = Math.min(...this.pollIntervals()); + const handle = setInterval(() => { + void this.runPollCycle(); + }, intervalSec * 1000); + + process.on('SIGINT', () => { + clearInterval(handle); + this.shutdown(); + process.exit(0); + }); + } + + shutdown(): void { + this.webhookServer?.close(); + this.tcpServer?.close(); + this.running = false; + } +} + +export function createSwiftListener(projectRoot: string): SwiftListener { + return new SwiftListener(projectRoot); +} diff --git a/services/swift-listener/src/matchers/resourceMatcher.ts b/services/swift-listener/src/matchers/resourceMatcher.ts new file mode 100644 index 0000000..a62f5f0 --- /dev/null +++ b/services/swift-listener/src/matchers/resourceMatcher.ts @@ -0,0 +1,113 @@ +import { ethers } from 'ethers'; +import type { ParsedInbound, ResourceMatch, ResourceRegistry } from '../types'; +import { collectSearchableFields } from '../resourceRegistry'; + +const ADDR_RE = /0x[a-fA-F0-9]{40}\b/g; +const CW_SYMBOL_RE = /\bcW[A-Z]{2,6}\b/g; +const C_SYMBOL_RE = /\bc[A-Z]{2,6}\b/g; + +function fieldLabel(index: number, snippet: string): string { + return `text[${index}]:${snippet.slice(0, 40)}`; +} + +export function matchResources( + registry: ResourceRegistry, + parsed: ParsedInbound +): ResourceMatch[] { + const matches: ResourceMatch[] = []; + const seen = new Set(); + const texts = [ + parsed.searchableText, + ...collectSearchableFields(parsed.parsed), + ].filter(Boolean); + + const push = (m: ResourceMatch) => { + const key = `${m.kind}|${m.value.toLowerCase()}|${m.field}`; + if (seen.has(key)) return; + seen.add(key); + matches.push(m); + }; + + texts.forEach((text, idx) => { + const lower = text.toLowerCase(); + + for (const name of registry.chainNames) { + if (lower.includes(name)) { + push({ kind: 'chain138_name', value: name, field: fieldLabel(idx, text) }); + } + } + + for (const prefix of registry.instructionPrefixes) { + if (text.includes(prefix)) { + push({ kind: 'instruction_prefix', value: prefix, field: fieldLabel(idx, text) }); + } + } + + for (const kw of registry.remittanceKeywords) { + if (lower.includes(kw)) { + push({ kind: 'remittance_keyword', value: kw, field: fieldLabel(idx, text) }); + } + } + + for (const sym of registry.symbols) { + const re = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); + if (re.test(text)) { + const kind = sym.startsWith('cW') ? 'cw_symbol' : 'chain138_symbol'; + push({ kind, value: sym, symbol: sym, field: fieldLabel(idx, text), chainId: sym.startsWith('cW') ? undefined : 138 }); + } + } + + for (const m of text.matchAll(CW_SYMBOL_RE)) { + const sym = m[0]; + if (registry.cwSymbols.has(sym) || registry.symbols.has(sym)) { + push({ kind: 'cw_symbol', value: sym, symbol: sym, field: fieldLabel(idx, text) }); + } + } + + for (const m of text.matchAll(C_SYMBOL_RE)) { + const sym = m[0]; + if (registry.symbols.has(sym)) { + push({ kind: 'chain138_symbol', value: sym, symbol: sym, chainId: 138, field: fieldLabel(idx, text) }); + } + } + + for (const m of text.matchAll(ADDR_RE)) { + const addr = m[0]; + const meta = registry.addresses.get(addr.toLowerCase()); + if (meta) { + push({ + kind: meta.chainId === 138 ? 'chain138_address' : 'cw_address', + value: ethers.getAddress(addr), + symbol: meta.symbol, + chainId: meta.chainId, + field: fieldLabel(idx, text), + }); + } + } + }); + + for (const c of registry.contracts) { + if (parsed.searchableText.toLowerCase().includes(c.toLowerCase())) { + push({ + kind: 'contract_ref', + value: ethers.getAddress(c), + chainId: 138, + field: 'searchableText', + }); + } + } + + return matches; +} + +export function currencyMatchesCwAsset(currency: string | undefined, registry: ResourceRegistry): boolean { + if (!currency) return false; + const c = currency.toUpperCase(); + for (const sym of registry.cwSymbols) { + if (c === sym.toUpperCase()) return true; + } + for (const sym of registry.symbols) { + if (c === sym.toUpperCase()) return true; + } + return false; +} diff --git a/services/swift-listener/src/outbound/dispatchOutcomes.ts b/services/swift-listener/src/outbound/dispatchOutcomes.ts new file mode 100644 index 0000000..b7f1746 --- /dev/null +++ b/services/swift-listener/src/outbound/dispatchOutcomes.ts @@ -0,0 +1,25 @@ +import type { ListenerConfig, ResourceRegistry, SwiftListenerEvent } from '../types'; +import { forwardToOmnl } from './omnlForwardAdapter'; +import { submitToIntakeGateway } from './intakeGatewayAdapter'; + +export async function dispatchMatchedOutbounds( + projectRoot: string, + config: ListenerConfig, + registry: ResourceRegistry, + event: SwiftListenerEvent +): Promise { + if (!event.matched || !event.canonical) return undefined; + + const outbound: NonNullable = {}; + + const omnlEnabled = config.outbound?.omnlForward?.enabled || config.omnlForward?.enabled; + if (omnlEnabled) { + outbound.omnl = await forwardToOmnl(projectRoot, config, event); + } + + if (config.outbound?.intakeGateway?.enabled) { + outbound.intakeGateway = await submitToIntakeGateway(config, registry, event.canonical); + } + + return outbound; +} diff --git a/services/swift-listener/src/outbound/intakeGatewayAdapter.ts b/services/swift-listener/src/outbound/intakeGatewayAdapter.ts new file mode 100644 index 0000000..8e546f1 --- /dev/null +++ b/services/swift-listener/src/outbound/intakeGatewayAdapter.ts @@ -0,0 +1,120 @@ +import { ethers } from 'ethers'; +import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core'; +import type { ListenerConfig, ResourceRegistry } from '../types'; + +const GATEWAY_ABI = [ + 'function submitInbound((bytes32 instructionId, bytes32 endToEndIdHash, bytes32 uetr, bytes32 payloadHash, bytes32 debtorRefHash, bytes32 creditorRefHash, bytes32 purposeHash, uint8 msgTypeCode, address token, uint256 amount, bytes3 currencyCode) m) external', + 'function processedInstructions(bytes32) view returns (bool)', +]; + +const RPC_TIMEOUT_MS = 15_000; + +async function withRpcTimeout(label: string, fn: () => Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${RPC_TIMEOUT_MS}ms`)), RPC_TIMEOUT_MS); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function toBytes3(code: string): string { + const b = new Uint8Array(3); + const enc = new TextEncoder().encode(code.slice(0, 3).toUpperCase()); + b.set(enc.slice(0, 3)); + return ethers.hexlify(b); +} + +function resolveTokenAddress( + canonical: CanonicalPaymentMessage, + registry: ResourceRegistry +): string { + if (canonical.tokenAddress && ethers.isAddress(canonical.tokenAddress)) { + return ethers.getAddress(canonical.tokenAddress); + } + for (const sym of [canonical.currencyCode, 'cUSDC', 'cUSDT']) { + for (const [addr, meta] of registry.addresses.entries()) { + if (meta.symbol?.toUpperCase() === sym.toUpperCase() && meta.chainId === 138) { + return ethers.getAddress(addr); + } + } + } + return ethers.ZeroAddress; +} + +export function buildGatewayMessage( + canonical: CanonicalPaymentMessage, + registry: ResourceRegistry +) { + return { + instructionId: canonical.instructionIdBytes32, + endToEndIdHash: canonical.endToEndIdHash, + uetr: canonical.uetrBytes32, + payloadHash: canonical.payloadHash, + debtorRefHash: canonical.debtorRefHash, + creditorRefHash: canonical.creditorRefHash, + purposeHash: canonical.purposeHash, + msgTypeCode: canonical.msgTypeCode, + token: resolveTokenAddress(canonical, registry), + amount: BigInt(canonical.amountSmallestUnit || '0'), + currencyCode: toBytes3(canonical.currencyCode || 'USD'), + }; +} + +function shouldExecuteSubmit(gwCfg: NonNullable['intakeGateway']): boolean { + const explicit = process.env[gwCfg.executeEnv]; + if (explicit === '1') return true; + if (explicit === '0') return false; + if (gwCfg.autoExecuteWhenKeyPresent) { + return Boolean(process.env[gwCfg.privateKeyEnv] ?? process.env.PRIVATE_KEY); + } + return !gwCfg.dryRunDefault; +} + +export async function submitToIntakeGateway( + config: ListenerConfig, + registry: ResourceRegistry, + canonical: CanonicalPaymentMessage +): Promise<{ dryRun: boolean; txHash?: string; error?: string; gateway?: string; prepared?: boolean }> { + const gwCfg = config.outbound?.intakeGateway; + if (!gwCfg?.enabled) return { dryRun: true }; + + const gateway = process.env[gwCfg.gatewayAddressEnv] ?? gwCfg.gatewayAddressFallback; + const rpc = process.env[gwCfg.rpcUrlEnv] ?? process.env.RPC_URL_138 ?? 'http://127.0.0.1:8545'; + const pk = process.env[gwCfg.privateKeyEnv] ?? process.env.PRIVATE_KEY ?? ''; + const execute = shouldExecuteSubmit(gwCfg); + + const message = buildGatewayMessage(canonical, registry); + + if (!execute) { + return { dryRun: true, gateway, prepared: true }; + } + + if (!pk) { + return { dryRun: false, gateway, error: 'PRIVATE_KEY not set' }; + } + + try { + const provider = new ethers.JsonRpcProvider(rpc, gwCfg.chainId); + const wallet = new ethers.Wallet(pk, provider); + const contract = new ethers.Contract(gateway, GATEWAY_ABI, wallet); + + const already = await withRpcTimeout('processedInstructions', () => + contract.processedInstructions(message.instructionId) + ); + if (already) { + return { dryRun: false, gateway, error: 'duplicate instruction' }; + } + + const tx = await withRpcTimeout('submitInbound', () => contract.submitInbound(message)); + const receipt = (await withRpcTimeout('tx.wait', () => tx.wait())) as ethers.TransactionReceipt | null; + return { dryRun: false, gateway, txHash: receipt?.hash ?? tx.hash }; + } catch (e) { + return { dryRun: false, gateway, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/services/swift-listener/src/outbound/omnlForwardAdapter.ts b/services/swift-listener/src/outbound/omnlForwardAdapter.ts new file mode 100644 index 0000000..49fd40a --- /dev/null +++ b/services/swift-listener/src/outbound/omnlForwardAdapter.ts @@ -0,0 +1,108 @@ +import { createHash, randomUUID } from 'crypto'; +import { mkdirSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; +import { canonicalToPacs008Xml } from '@dbis/checkpoint-core'; +import type { ListenerConfig, SwiftListenerEvent } from '../types'; + +export const OMNL_ISO20022_API_PATH = '/api/v1/omnl/iso20022/messages'; + +type OmnlMessageType = 'pain.001' | 'pacs.008' | 'camt.053' | 'other'; + +function mapOmnlMessageType(event: SwiftListenerEvent): OmnlMessageType { + const t = event.messageType.toLowerCase(); + if (t.includes('pain.001')) return 'pain.001'; + if (t.includes('pacs.008') || t === 'mt103') return 'pacs.008'; + if (t.includes('camt.053')) return 'camt.053'; + if (event.format === 'swift_fin' && event.messageType === 'MT103') return 'pacs.008'; + return 'other'; +} + +function payloadForStore(event: SwiftListenerEvent): string { + if (event.format === 'iso20022') return event.rawPreview; + if (event.canonical) return canonicalToPacs008Xml(event.canonical); + return event.rawPreview; +} + +function localStoreDir(projectRoot: string, config: ListenerConfig): string { + const outbound = config.outbound?.omnlForward; + const rel = outbound?.localStoreDir ?? 'config/iso20022-omnl/messages'; + const envDir = process.env.OMNL_ISO20022_STORE_DIR?.trim(); + if (envDir) return resolve(envDir); + return resolve(projectRoot, rel); +} + +export async function forwardToOmnl( + projectRoot: string, + config: ListenerConfig, + event: SwiftListenerEvent +): Promise<{ stored?: string; apiStatus?: number; apiPath?: string; error?: string }> { + const outbound = config.outbound?.omnlForward; + const legacyEnabled = config.omnlForward?.enabled; + if (!outbound?.enabled && !legacyEnabled) return {}; + + const mode = outbound?.mode ?? 'local_and_api'; + const payload = payloadForStore(event); + const messageType = mapOmnlMessageType(event); + const result: { stored?: string; apiStatus?: number; apiPath?: string; error?: string } = {}; + + if (mode === 'local' || mode === 'local_and_api') { + const dir = localStoreDir(projectRoot, config); + mkdirSync(dir, { recursive: true }); + const id = randomUUID(); + const receivedAt = new Date().toISOString(); + const payloadSha256 = createHash('sha256').update(payload, 'utf8').digest('hex'); + const record = { + id, + messageType, + receivedAt, + retentionUntil: new Date( + new Date(receivedAt).setFullYear(new Date(receivedAt).getFullYear() + 10) + ).toISOString(), + uetr: event.canonical?.uetr, + instructionId: event.canonical?.instructionId, + settlementOrChainRef: event.canonical?.chain138TxHash || undefined, + payloadSha256, + payload, + swiftListenerEventId: event.id, + source: event.source, + }; + writeFileSync(join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf8'); + result.stored = id; + } + + if (mode === 'api' || mode === 'local_and_api') { + const urlEnv = outbound?.tokenAggregationUrlEnv ?? config.omnlForward?.tokenAggregationUrlEnv ?? 'TOKEN_AGGREGATION_URL'; + const keyEnv = outbound?.apiKeyEnv ?? config.omnlForward?.apiKeyEnv ?? 'OMNL_API_KEY'; + const base = process.env[urlEnv] ?? process.env.TOKEN_AGGREGATION_URL ?? 'http://127.0.0.1:3000'; + const apiKey = process.env[keyEnv] ?? process.env.OMNL_API_KEY; + + try { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (apiKey) headers['X-API-Key'] = apiKey; + + const apiPath = outbound?.apiPath ?? OMNL_ISO20022_API_PATH; + const res = await fetch(`${base.replace(/\/$/, '')}${apiPath}`, { + method: 'POST', + headers, + body: JSON.stringify({ + messageType, + payload, + uetr: event.canonical?.uetr, + instructionId: event.canonical?.instructionId, + settlementOrChainRef: event.canonical?.chain138TxHash, + accountingRef: `swift-listener:${event.id}`, + }), + }); + result.apiStatus = res.status; + result.apiPath = apiPath; + if (!res.ok) result.error = `OMNL API ${res.status}`; + } catch (e) { + result.error = e instanceof Error ? e.message : String(e); + } + } + + return result; +} diff --git a/services/swift-listener/src/parsers/inboundParser.ts b/services/swift-listener/src/parsers/inboundParser.ts new file mode 100644 index 0000000..15d2e33 --- /dev/null +++ b/services/swift-listener/src/parsers/inboundParser.ts @@ -0,0 +1,262 @@ +import type { ParsedInbound, ParsedIso20022, ParsedJsonBroadcast, ParsedSwiftFin, SwiftFinMessageType } from '../types'; + +function parseSwiftFields(text: string): Record { + const fields: Record = {}; + let currentField: string | null = null; + let currentValue: string[] = []; + + for (const line of text.split('\n')) { + const m = line.match(/^:(\d{2}[A-Z]?):(.*)$/); + if (m) { + if (currentField) fields[currentField] = currentValue.join('\n').trim(); + currentField = m[1]; + currentValue = [m[2] ?? '']; + } else if (currentField) { + currentValue.push(line); + } + } + if (currentField) fields[currentField] = currentValue.join('\n').trim(); + return fields; +} + +function extractBraceBlocks(raw: string): Map { + const blocks = new Map(); + const b2 = raw.match(/\{2:([^}]*)\}/); + if (b2) blocks.set(2, b2[1].trim()); + const b4 = raw.match(/\{4:([\s\S]*?)-\}/); + if (b4) blocks.set(4, b4[1].trim()); + return blocks; +} + +function parseBlock(lines: string[], blockNum: number, braceBlocks?: Map): string { + if (braceBlocks?.has(blockNum)) return braceBlocks.get(blockNum)!; + + const blockStart = `${blockNum}:`; + const blockEnd = `-${blockNum}`; + const out: string[] = []; + let inBlock = false; + for (const line of lines) { + if (line.startsWith(blockStart)) { + inBlock = true; + out.push(line.slice(blockStart.length)); + continue; + } + if (line.startsWith(blockEnd)) break; + if (inBlock) out.push(line); + } + return out.join('\n'); +} + +function parseTextBlock(lines: string[], braceBlocks?: Map): string { + if (braceBlocks?.has(4)) { + const text = braceBlocks.get(4)!; + return text.startsWith(':') ? text : `:${text}`; + } + + const out: string[] = []; + let inText = false; + for (const line of lines) { + if (line.startsWith('4:')) { + inText = true; + out.push(line.slice(2)); + continue; + } + if (line.startsWith('-') && !line.startsWith('-4')) break; + if (inText) out.push(line); + } + return out.join('\n'); +} + +function extractMessageType(appHeader: string): SwiftFinMessageType { + const mt = appHeader.match(/MT(\d{3})/); + if (mt) return `MT${mt[1]}` as SwiftFinMessageType; + const io = appHeader.match(/[IO](\d{3})/); + if (io) return `MT${io[1]}` as SwiftFinMessageType; + return 'UNKNOWN'; +} + +function split32A(v: string): { valueDate: string; currency: string; amount: string } { + const parts = v.trim().split(/\s+/); + return { + valueDate: parts[0] ?? '', + currency: parts[1] ?? '', + amount: parts[2] ?? '', + }; +} + +function parseMt103(fields: Record) { + const amt = split32A(fields['32A'] ?? ''); + return { + senderReference: fields['20'] ?? '', + bankOperationCode: fields['23B'] ?? '', + ...amt, + orderingCustomer: fields['50A'] ?? fields['50K'] ?? '', + sendingInstitution: fields['52A'] ?? fields['52D'] ?? '', + orderingInstitution: fields['56A'] ?? fields['56C'] ?? fields['56D'] ?? '', + beneficiaryCustomer: fields['59'] ?? fields['59A'] ?? '', + remittanceInfo: fields['70'] ?? '', + senderToReceiverInfo: fields['72'] ?? '', + }; +} + +function parseMt202(fields: Record) { + const amt = split32A(fields['32A'] ?? ''); + return { + senderReference: fields['20'] ?? '', + transactionReference: fields['21'] ?? '', + ...amt, + sendingInstitution: fields['52A'] ?? fields['52D'] ?? '', + orderingInstitution: fields['56A'] ?? fields['56C'] ?? fields['56D'] ?? '', + accountWithInstitution: fields['57A'] ?? fields['57C'] ?? fields['57D'] ?? '', + beneficiaryInstitution: fields['58A'] ?? fields['58D'] ?? '', + remittanceInfo: fields['70'] ?? '', + senderToReceiverInfo: fields['72'] ?? '', + }; +} + +function parseMt910(fields: Record) { + const amt = split32A(fields['32A'] ?? ''); + return { + transactionReference: fields['20'] ?? '', + relatedReference: fields['21'] ?? '', + ...amt, + orderingInstitution: fields['52A'] ?? fields['52D'] ?? '', + accountWithInstitution: fields['57A'] ?? fields['57D'] ?? '', + remittanceInfo: fields['72'] ?? '', + }; +} + +function parseStatement(fields: Record, text: string) { + return { + statementNumber: fields['28C'] ?? '', + accountIdentification: fields['25'] ?? '', + openingBalance: fields['60F'] ?? fields['60M'] ?? '', + closingBalance: fields['62F'] ?? fields['62M'] ?? '', + transactionLines: (fields['61'] ?? '').split('\n').filter(Boolean), + narrative: fields['86'] ?? '', + rawFieldCount: Object.keys(fields).length, + textLength: text.length, + }; +} + +export function parseSwiftFin(raw: string): ParsedSwiftFin { + const lines = raw.trim().split('\n'); + const braceBlocks = extractBraceBlocks(raw); + const appHeader = parseBlock(lines, 2, braceBlocks); + const text = parseTextBlock(lines, braceBlocks); + const messageType = extractMessageType(appHeader); + const fields = parseSwiftFields(text.startsWith(':') ? text : `\n${text}`); + + let parsed: Record; + switch (messageType) { + case 'MT103': + parsed = parseMt103(fields); + break; + case 'MT202': + parsed = parseMt202(fields); + break; + case 'MT910': + parsed = parseMt910(fields); + break; + case 'MT940': + case 'MT942': + case 'MT950': + parsed = parseStatement(fields, text); + break; + default: + parsed = { fields, textPreview: text.slice(0, 500) }; + } + + return { + format: 'swift_fin', + messageType, + raw, + parsed, + searchableText: [text, JSON.stringify(parsed), appHeader].join('\n'), + }; +} + +export function detectInboundFormat(payload: string): 'swift_fin' | 'iso20022' | 'json_broadcast' { + const t = payload.trim(); + if (t.startsWith(' { + const re = new RegExp(`<(?:[\\w-]+:)?${name}[^>]*>([^<]*)`, 'i'); + return raw.match(re)?.[1]?.trim() ?? ''; + }; + + const parsed: Record = { + messageId: tag('MsgId'), + instructionId: tag('InstrId'), + endToEndId: tag('EndToEndId'), + uetr: tag('UETR'), + txId: tag('TxId'), + amount: tag('InstdAmt') || tag('IntrBkSttlmAmt'), + currency: + raw.match(/<(?:[\w-]+:)?InstdAmt[^>]* Ccy="([^"]+)"/i)?.[1] ?? + raw.match(/<(?:[\w-]+:)?IntrBkSttlmAmt[^>]* Ccy="([^"]+)"/i)?.[1] ?? + '', + debtor: tag('Dbtr') || tag('Nm'), + creditor: tag('Cdtr'), + remittance: tag('Ustrd'), + purpose: tag('Purp'), + }; + + return { + format: 'iso20022', + messageType, + raw, + parsed, + searchableText: raw, + }; +} + +export function parseJsonBroadcast(raw: string): ParsedJsonBroadcast { + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + parsed = { raw }; + } + + const messageType = + String(parsed.messageType ?? parsed.type ?? parsed.channel ?? parsed.status ?? 'json_broadcast'); + + return { + format: 'json_broadcast', + messageType, + raw, + parsed, + searchableText: JSON.stringify(parsed), + }; +} + +export function parseInbound(payload: string): ParsedInbound { + const format = detectInboundFormat(payload); + if (format === 'swift_fin') return parseSwiftFin(payload); + if (format === 'iso20022') return parseIso20022(payload); + return parseJsonBroadcast(payload); +} diff --git a/services/swift-listener/src/resourceRegistry.ts b/services/swift-listener/src/resourceRegistry.ts new file mode 100644 index 0000000..aa7c881 --- /dev/null +++ b/services/swift-listener/src/resourceRegistry.ts @@ -0,0 +1,206 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import type { ListenerConfig, ResourceRegistry } from './types'; + +function readJson(path: string): T | null { + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +function normAddr(a: string): string { + return a.toLowerCase(); +} + +export function loadListenerConfig(projectRoot: string): ListenerConfig { + const p = resolve(projectRoot, 'config/swift-listener-chain138.v1.json'); + const config = readJson(p)!; + applyEnvOverrides(config); + return config; +} + +function envFlag(name: string): boolean { + return process.env[name] === '1'; +} + +export function applyEnvOverrides(config: ListenerConfig): void { + if (process.env.SWIFT_LISTENER_FILE_INBOX === '1') config.adapters.fileInbox.enabled = true; + if (process.env.SWIFT_LISTENER_FILE_INBOX === '0') config.adapters.fileInbox.enabled = false; + + if (envFlag('SWIFT_LISTENER_WEBHOOK')) config.adapters.webhook.enabled = true; + if (process.env.SWIFT_LISTENER_WEBHOOK === '0') config.adapters.webhook.enabled = false; + + if (envFlag('SWIFT_LISTENER_P2P_RAIL')) config.adapters.p2pRail.enabled = true; + if (envFlag('SWIFT_LISTENER_FIN_GATEWAY')) config.adapters.finGateway.enabled = true; + if (envFlag('SWIFT_LISTENER_FIN_TCP')) config.adapters.swiftFinTcp.enabled = true; + if (process.env.SWIFT_LISTENER_FIN_TCP === '0') config.adapters.swiftFinTcp.enabled = false; + + if (envFlag('SWIFT_LISTENER_AS4_INBOX')) config.adapters.as4Inbox.enabled = true; + if (envFlag('SWIFT_LISTENER_MAIL_INBOX')) config.adapters.mailAttachmentInbox.enabled = true; + + config.outbound ??= defaultOutboundConfig(); + + if (envFlag('SWIFT_LISTENER_OMNL_FORWARD')) config.outbound.omnlForward.enabled = true; + if (process.env.SWIFT_LISTENER_OMNL_FORWARD === '0') config.outbound.omnlForward.enabled = false; + + if (envFlag('SWIFT_LISTENER_INTAKE_GATEWAY')) config.outbound.intakeGateway.enabled = true; + if (process.env.SWIFT_LISTENER_INTAKE_GATEWAY === '0') config.outbound.intakeGateway.enabled = false; + + applyAttestationEnvFlags(config); +} + +/** Align with config/chain138-iso20022-attestation.v1.json envFlags when set on operator host. */ +function applyAttestationEnvFlags(config: ListenerConfig): void { + if (process.env.CHECKPOINT_ISO_OMNL_STORE === '1') { + config.outbound ??= defaultOutboundConfig(); + config.outbound.omnlForward.enabled = true; + } + if (process.env.CHECKPOINT_RECORD_ISO_ATTESTATION === '1') { + config.outbound ??= defaultOutboundConfig(); + config.outbound.intakeGateway.enabled = true; + } +} + +function defaultOutboundConfig(): NonNullable { + return { + omnlForward: { + enabled: true, + mode: 'local_and_api', + apiPath: '/api/v1/omnl/iso20022/messages', + tokenAggregationUrlEnv: 'TOKEN_AGGREGATION_URL', + apiKeyEnv: 'OMNL_API_KEY', + localStoreDir: 'config/iso20022-omnl/messages', + }, + intakeGateway: { + enabled: true, + gatewayAddressEnv: 'ISO20022_INTAKE_GATEWAY_MAINNET', + gatewayAddressFallback: '0x66C89F91A4e830d87fcE4E5F86aFA71A96FbDa3d', + rpcUrlEnv: 'RPC_URL_138', + privateKeyEnv: 'PRIVATE_KEY', + executeEnv: 'SWIFT_LISTENER_GATEWAY_EXECUTE', + dryRunDefault: false, + autoExecuteWhenKeyPresent: true, + chainId: 138, + }, + }; +} + +/** Build searchable registry from Chain 138 + cW* config sources. */ +export function buildResourceRegistry(projectRoot: string, config: ListenerConfig): ResourceRegistry { + const symbols = new Set(); + const cwSymbols = new Set(Object.values(config.cToCwSymbolMapping)); + const addresses = new Map(); + const contracts: string[] = []; + + for (const [cSym, cwSym] of Object.entries(config.cToCwSymbolMapping)) { + symbols.add(cSym); + symbols.add(cwSym); + cwSymbols.add(cwSym); + } + + const ei = readJson<{ + canonicalTokens?: Array<{ symbol: string; address: string }>; + dodoStackA?: { integration?: string; provider?: string; pools?: Array<{ pool: string; name?: string }> }; + enhancedSwapRouter?: string; + enhancedSwapRouterV2?: string; + uniswapV3Native?: { factory?: string; swapRouter?: string; pools?: Array<{ pool: string }> }; + }>(resolve(projectRoot, 'config/ei-matrix-138-assets-and-pools.v1.json')); + + if (ei?.canonicalTokens) { + for (const t of ei.canonicalTokens) { + symbols.add(t.symbol); + addresses.set(normAddr(t.address), { symbol: t.symbol, chainId: 138 }); + } + } + if (ei?.dodoStackA?.integration) { + contracts.push(ei.dodoStackA.integration); + addresses.set(normAddr(ei.dodoStackA.integration), { symbol: 'DODOPMMIntegration', chainId: 138 }); + } + if (ei?.dodoStackA?.provider) { + contracts.push(ei.dodoStackA.provider); + addresses.set(normAddr(ei.dodoStackA.provider), { symbol: 'DODOPMMProvider', chainId: 138 }); + } + for (const pool of ei?.dodoStackA?.pools ?? []) { + contracts.push(pool.pool); + addresses.set(normAddr(pool.pool), { symbol: pool.name ?? 'PMM_POOL', chainId: 138 }); + } + if (ei?.enhancedSwapRouter) { + contracts.push(ei.enhancedSwapRouter); + addresses.set(normAddr(ei.enhancedSwapRouter), { symbol: 'EnhancedSwapRouter', chainId: 138 }); + } + if (ei?.enhancedSwapRouterV2) { + contracts.push(ei.enhancedSwapRouterV2); + addresses.set(normAddr(ei.enhancedSwapRouterV2), { symbol: 'EnhancedSwapRouterV2', chainId: 138 }); + } + for (const pool of ei?.uniswapV3Native?.pools ?? []) { + contracts.push(pool.pool); + addresses.set(normAddr(pool.pool), { symbol: 'UniV3Pool', chainId: 138 }); + } + + const attestation = readJson<{ contracts?: Record }>( + resolve(projectRoot, 'config/chain138-iso20022-attestation.v1.json') + ); + for (const addr of Object.values(attestation?.contracts ?? {})) { + if (addr.startsWith('0x') && addr.length === 42) { + contracts.push(addr); + addresses.set(normAddr(addr), { chainId: 138 }); + } + } + + const mapping = readJson<{ + cToCwSymbolMapping?: Record; + pairs?: Array<{ + fromChainId: number; + toChainId: number; + tokens?: Array<{ key: string; addressFrom?: string; addressTo?: string }>; + }>; + }>(resolve(projectRoot, 'config/token-mapping-multichain.json')); + + for (const [c, cw] of Object.entries(mapping?.cToCwSymbolMapping ?? config.cToCwSymbolMapping)) { + symbols.add(c); + symbols.add(cw); + cwSymbols.add(cw); + } + + for (const pair of mapping?.pairs ?? []) { + for (const tok of pair.tokens ?? []) { + if (tok.addressFrom?.startsWith('0x') && tok.addressFrom.length === 42 && pair.fromChainId === 138) { + addresses.set(normAddr(tok.addressFrom), { symbol: tok.key, chainId: 138 }); + } + if (tok.addressTo?.startsWith('0x') && tok.addressTo.length === 42 && tok.addressTo !== '0x0000000000000000000000000000000000000000') { + const sym = tok.key.includes('_cW') ? config.cToCwSymbolMapping[tok.key.replace('Compliant_', '').replace('_cW', '')] ?? tok.key : tok.key; + addresses.set(normAddr(tok.addressTo), { symbol: sym, chainId: pair.toChainId }); + if (String(sym).startsWith('cW')) cwSymbols.add(String(sym)); + } + } + } + + return { + chainId: config.chain138.chainId, + chainNames: config.chain138.names.map((n) => n.toLowerCase()), + symbols, + cwSymbols, + addresses, + instructionPrefixes: config.matchHints.instructionIdPrefixes, + remittanceKeywords: config.matchHints.remittanceKeywords.map((k) => k.toLowerCase()), + contracts, + }; +} + +export function collectSearchableFields(parsed: Record, prefix = ''): string[] { + const out: string[] = []; + for (const [k, v] of Object.entries(parsed)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v == null) continue; + if (typeof v === 'string') out.push(v); + else if (typeof v === 'number' || typeof v === 'boolean') out.push(String(v)); + else if (Array.isArray(v)) { + for (const item of v) { + if (typeof item === 'string') out.push(item); + else if (item && typeof item === 'object') out.push(...collectSearchableFields(item as Record, path)); + } + } else if (typeof v === 'object') { + out.push(...collectSearchableFields(v as Record, path)); + } + } + return out; +} diff --git a/services/swift-listener/src/store/eventStore.ts b/services/swift-listener/src/store/eventStore.ts new file mode 100644 index 0000000..eb6849c --- /dev/null +++ b/services/swift-listener/src/store/eventStore.ts @@ -0,0 +1,125 @@ +import { createHash, randomUUID } from 'crypto'; +import { + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + renameSync, + appendFileSync, +} from 'fs'; +import { dirname, join, resolve } from 'path'; +import type { ListenerConfig, SwiftListenerEvent } from '../types'; + +function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export class EventStore { + private processed = new Set(); + + constructor( + private readonly projectRoot: string, + private readonly config: ListenerConfig + ) { + this.loadProcessedIndex(); + } + + private paths() { + const s = this.config.storage; + return { + eventsDir: resolve(this.projectRoot, s.eventsDir), + latestStatus: resolve(this.projectRoot, s.latestStatus), + processedIndex: resolve(this.projectRoot, s.processedIndex), + inboxDir: resolve(this.projectRoot, s.inboxDir), + archiveDir: resolve(this.projectRoot, s.archiveDir), + }; + } + + private loadProcessedIndex(): void { + const { processedIndex } = this.paths(); + if (!existsSync(processedIndex)) return; + try { + const data = JSON.parse(readFileSync(processedIndex, 'utf8')) as { keys?: string[] }; + for (const k of data.keys ?? []) this.processed.add(k); + } catch { + // fresh index on parse failure + } + } + + private persistProcessedIndex(): void { + const { processedIndex } = this.paths(); + ensureDir(dirname(processedIndex)); + writeFileSync(processedIndex, JSON.stringify({ keys: [...this.processed].slice(-50000) }, null, 2)); + } + + dedupeKey(source: string, payload: string): string { + return createHash('sha256').update(`${source}\n${payload}`).digest('hex'); + } + + isProcessed(key: string): boolean { + return this.processed.has(key); + } + + saveEvent(event: SwiftListenerEvent): void { + const { eventsDir, latestStatus } = this.paths(); + ensureDir(eventsDir); + const eventPath = join(eventsDir, `${event.id}.json`); + writeFileSync(eventPath, JSON.stringify(event, null, 2)); + + const status = { + updatedAt: new Date().toISOString(), + lastEventId: event.id, + lastMatched: event.matched, + lastMessageType: event.messageType, + lastSource: event.source, + matchCount: event.matches.length, + outbound: event.outbound ?? null, + }; + ensureDir(dirname(latestStatus)); + writeFileSync(latestStatus, JSON.stringify(status, null, 2)); + + this.processed.add(event.dedupeKey); + this.persistProcessedIndex(); + + appendFileSync( + join(eventsDir, '..', 'events.jsonl'), + `${JSON.stringify({ id: event.id, at: event.receivedAt, matched: event.matched, type: event.messageType, source: event.source })}\n` + ); + } + + inboxPath(): string { + return this.paths().inboxDir; + } + + archiveFile(fromPath: string): void { + const { archiveDir } = this.paths(); + ensureDir(archiveDir); + const base = fromPath.split('/').pop() ?? randomUUID(); + renameSync(fromPath, join(archiveDir, `${Date.now()}-${base}`)); + } +} + +export function buildEvent( + source: string, + parsed: SwiftListenerEvent['parsed'], + format: SwiftListenerEvent['format'], + messageType: string, + matches: SwiftListenerEvent['matches'], + canonical: SwiftListenerEvent['canonical'], + raw: string, + dedupeKey: string +): SwiftListenerEvent { + return { + id: randomUUID(), + receivedAt: new Date().toISOString(), + source, + format, + messageType, + matched: matches.length > 0, + matches, + parsed, + canonical, + rawPreview: raw.slice(0, 2000), + dedupeKey, + }; +} diff --git a/services/swift-listener/src/swift-listener.test.ts b/services/swift-listener/src/swift-listener.test.ts new file mode 100644 index 0000000..2d03e24 --- /dev/null +++ b/services/swift-listener/src/swift-listener.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { resolve } from 'path'; +import { buildResourceRegistry, loadListenerConfig } from '../src/resourceRegistry'; +import { parseSwiftFin, parseIso20022, parseInbound } from '../src/parsers/inboundParser'; +import { matchResources } from '../src/matchers/resourceMatcher'; +import { createSwiftListener } from '../src/listener'; + +const ROOT = resolve(__dirname, '../../../..'); + +const SAMPLE_MT103 = `{1:F01BANKUS33AXXX0000000000}{2:I103BANKDEFFXXXXN}{3:{108:MT103}}{4: +:20:CHAIN138-abc123ref +:23B:CRED +:32A:230101USD1000,00 +:50K:/123456 +Ordering Customer +:59:/987654 +Beneficiary +:70:cWUSDC settlement on chain138 via 0xf22258f57794CC8E06237084b353Ab30fFfa640b +-}`; + +beforeEach(() => { + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ id: 'test' }), { status: 201 })) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('swift-listener parsers', () => { + it('parses MT103 with remittance referencing cWUSDC and chain138 address', () => { + const parsed = parseSwiftFin(SAMPLE_MT103); + expect(parsed.messageType).toBe('MT103'); + expect(parsed.parsed.senderReference).toBe('CHAIN138-abc123ref'); + expect(parsed.parsed.remittanceInfo).toContain('cWUSDC'); + }); + + it('detects iso20022 pacs.008', () => { + const xml = ` + MSG1 + CHAIN138-test + 100 + Defi Oracle Meta chain 138 cUSDT pool + `; + const parsed = parseIso20022(xml); + expect(parsed.messageType).toBe('pacs.008'); + expect(parsed.parsed.instructionId).toBe('CHAIN138-test'); + }); +}); + +describe('resource matcher', () => { + it('matches Chain 138 symbols, addresses, and cW* references', () => { + const config = loadListenerConfig(ROOT); + const registry = buildResourceRegistry(ROOT, config); + const parsed = parseInbound(SAMPLE_MT103); + const matches = matchResources(registry, parsed); + expect(matches.some((m) => m.kind === 'instruction_prefix')).toBe(true); + expect(matches.some((m) => m.kind === 'cw_symbol' && m.value === 'cWUSDC')).toBe(true); + expect(matches.some((m) => m.kind === 'chain138_address')).toBe(true); + }); +}); + +describe('listener pipeline', () => { + it('processes matched inbound and skips duplicates', async () => { + const listener = createSwiftListener(ROOT); + const msg = { + source: `test:mt103:${Date.now()}`, + format: 'swift_fin' as const, + payload: SAMPLE_MT103, + }; + const first = await listener.processMessage(msg); + expect(first?.matched).toBe(true); + expect(first?.matches.length).toBeGreaterThan(0); + expect(first?.canonical?.instructionId).toContain('CHAIN138'); + const second = await listener.processMessage(msg); + expect(second).toBeNull(); + }); +}); diff --git a/services/swift-listener/src/types.ts b/services/swift-listener/src/types.ts new file mode 100644 index 0000000..32914e4 --- /dev/null +++ b/services/swift-listener/src/types.ts @@ -0,0 +1,210 @@ +import type { CanonicalPaymentMessage } from '@dbis/checkpoint-core'; + +export type SwiftFinMessageType = + | 'MT103' + | 'MT202' + | 'MT910' + | 'MT940' + | 'MT942' + | 'MT950' + | 'UNKNOWN'; + +export type Iso20022MessageType = + | 'pain.001' + | 'pacs.008' + | 'pacs.009' + | 'pacs.002' + | 'camt.052' + | 'camt.053' + | 'camt.054' + | 'unknown'; + +export type InboundFormat = 'swift_fin' | 'iso20022' | 'json_broadcast'; + +export type ResourceMatchKind = + | 'chain138_name' + | 'chain138_address' + | 'chain138_symbol' + | 'cw_symbol' + | 'cw_address' + | 'instruction_prefix' + | 'remittance_keyword' + | 'contract_ref'; + +export type ResourceMatch = { + kind: ResourceMatchKind; + value: string; + field: string; + symbol?: string; + chainId?: number; +}; + +export type ParsedSwiftFin = { + format: 'swift_fin'; + messageType: SwiftFinMessageType; + raw: string; + parsed: Record; + searchableText: string; +}; + +export type ParsedIso20022 = { + format: 'iso20022'; + messageType: Iso20022MessageType; + raw: string; + parsed: Record; + searchableText: string; +}; + +export type ParsedJsonBroadcast = { + format: 'json_broadcast'; + messageType: string; + raw: string; + parsed: Record; + searchableText: string; +}; + +export type ParsedInbound = ParsedSwiftFin | ParsedIso20022 | ParsedJsonBroadcast; + +export type SwiftListenerEvent = { + id: string; + receivedAt: string; + source: string; + format: InboundFormat; + messageType: string; + matched: boolean; + matches: ResourceMatch[]; + parsed: Record; + canonical?: CanonicalPaymentMessage; + rawPreview: string; + dedupeKey: string; + outbound?: OutboundDispatchResult; +}; + +export type OutboundDispatchResult = { + omnl?: { stored?: string; apiStatus?: number; apiPath?: string; error?: string }; + intakeGateway?: { + dryRun: boolean; + txHash?: string; + error?: string; + gateway?: string; + prepared?: boolean; + }; +}; + +export type ListenerConfig = { + schemaVersion: string; + chain138: { + chainId: number; + names: string[]; + rpcHint?: string; + configSources?: string[]; + }; + supportedSwiftFinTypes: SwiftFinMessageType[]; + supportedIso20022Types: Iso20022MessageType[]; + cToCwSymbolMapping: Record; + matchHints: { + instructionIdPrefixes: string[]; + remittanceKeywords: string[]; + }; + storage: { + eventsDir: string; + latestStatus: string; + processedIndex: string; + inboxDir: string; + archiveDir: string; + as4InboxDir?: string; + mailInboxDir?: string; + }; + adapters: { + fileInbox: { enabled: boolean; pollIntervalSec: number; archiveOnProcess?: boolean }; + webhook: { + enabled: boolean; + port: number; + path: string; + healthPath?: string; + authBearerEnv?: string; + webhookSecretEnv?: string; + webhookSignatureHeader?: string; + }; + p2pRail: { + enabled: boolean; + baseUrlEnv: string; + pollIntervalSec: number; + transactionsPath: string; + }; + finGateway: { + enabled: boolean; + baseUrlEnv: string; + fallbackBaseUrlEnv?: string; + pollIntervalSec: number; + messagesPath: string; + authBearerEnv?: string; + }; + swiftFinTcp: { + enabled: boolean; + host: string; + portEnv: string; + defaultPort: number; + }; + as4Inbox: { + enabled: boolean; + pollIntervalSec: number; + archiveOnProcess?: boolean; + }; + mailAttachmentInbox: { + enabled: boolean; + pollIntervalSec: number; + archiveOnProcess?: boolean; + }; + }; + outbound?: { + omnlForward: { + enabled: boolean; + mode: 'local' | 'api' | 'local_and_api'; + apiPath?: string; + tokenAggregationUrlEnv: string; + apiKeyEnv: string; + localStoreDir: string; + }; + intakeGateway: { + enabled: boolean; + gatewayAddressEnv: string; + gatewayAddressFallback: string; + rpcUrlEnv: string; + privateKeyEnv: string; + executeEnv: string; + dryRunDefault: boolean; + autoExecuteWhenKeyPresent?: boolean; + chainId: number; + }; + }; + /** @deprecated use outbound.omnlForward */ + omnlForward?: { + enabled: boolean; + tokenAggregationUrlEnv: string; + apiKeyEnv: string; + }; +}; + +export type ResourceRegistry = { + chainId: number; + chainNames: string[]; + symbols: Set; + cwSymbols: Set; + addresses: Map; + instructionPrefixes: string[]; + remittanceKeywords: string[]; + contracts: string[]; +}; + +export type SwiftListenerOptions = { + /** Process one batch and return (for --once). */ + once?: boolean; +}; + +export type InboundMessage = { + source: string; + format: InboundFormat; + payload: string; + metadata?: Record; +}; diff --git a/services/swift-listener/src/webhookServer.test.ts b/services/swift-listener/src/webhookServer.test.ts new file mode 100644 index 0000000..20e5692 --- /dev/null +++ b/services/swift-listener/src/webhookServer.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createServer, type Server } from 'http'; +import { computeWebhookSignature } from '@dbis/integration-foundation'; +import { createWebhookServer } from './adapters/webhookServer'; + +describe('webhookServer', () => { + let server: Server | undefined; + const port = 18765; + const secret = 'test-webhook-secret-dummy'; + + afterEach(() => { + server?.close(); + server = undefined; + }); + + it('rejects invalid HMAC signature', async () => { + const messages: unknown[] = []; + server = createWebhookServer({ + port, + path: '/webhook', + webhookSecret: secret, + onMessage: async (msg) => { + messages.push(msg); + }, + }); + + await new Promise((r) => setTimeout(r, 100)); + + const res = await fetch(`http://127.0.0.1:${port}/webhook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-hybx-signature': 'bad' }, + body: '{"test":true}', + }); + expect(res.status).toBe(401); + expect(messages.length).toBe(0); + }); + + it('accepts valid HMAC signature and sets correlation headers', async () => { + const messages: unknown[] = []; + server = createWebhookServer({ + port, + path: '/webhook', + webhookSecret: secret, + onMessage: async (msg) => { + messages.push(msg); + }, + }); + + await new Promise((r) => setTimeout(r, 100)); + + const body = '{"eventType":"payment.status"}'; + const sig = computeWebhookSignature(secret, body); + const res = await fetch(`http://127.0.0.1:${port}/webhook`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-hybx-signature': sig, + 'x-idempotency-key': 'test-key-001', + }, + body, + }); + expect(res.status).toBe(202); + expect(res.headers.get('x-correlation-id')).toBeTruthy(); + expect(messages.length).toBe(1); + }); + + it('returns 409 on duplicate idempotency key', async () => { + server = createWebhookServer({ + port: port + 1, + path: '/webhook', + webhookSecret: secret, + onMessage: async () => {}, + }); + + await new Promise((r) => setTimeout(r, 100)); + + const body = '{"eventType":"dup"}'; + const sig = computeWebhookSignature(secret, body); + const headers = { + 'Content-Type': 'application/json', + 'x-hybx-signature': sig, + 'x-idempotency-key': 'dup-key-001', + }; + const url = `http://127.0.0.1:${port + 1}/webhook`; + const first = await fetch(url, { method: 'POST', headers, body }); + const second = await fetch(url, { method: 'POST', headers, body }); + expect(first.status).toBe(202); + expect(second.status).toBe(409); + }); +}); diff --git a/services/swift-listener/src/wiring.test.ts b/services/swift-listener/src/wiring.test.ts new file mode 100644 index 0000000..c787f4a --- /dev/null +++ b/services/swift-listener/src/wiring.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { resolve } from 'path'; +import { loadListenerConfig } from '../src/resourceRegistry'; +import { createSwiftListener } from '../src/listener'; +import { dispatchMatchedOutbounds } from '../src/outbound/dispatchOutcomes'; +import { buildResourceRegistry } from '../src/resourceRegistry'; +import { forwardToOmnl, OMNL_ISO20022_API_PATH } from '../src/outbound/omnlForwardAdapter'; +import { mapToCanonical } from '../src/canonical/mapToCanonical'; +import { parseSwiftFin } from '../src/parsers/inboundParser'; +import type { SwiftListenerEvent } from '../src/types'; + +const ROOT = resolve(__dirname, '../../../..'); + +const SAMPLE_MT103 = `{1:F01BANKUS33AXXX0000000000}{2:I103BANKDEFFXXXXN}{4: +:20:CHAIN138-wire-test +:32A:230101USD500,00 +:70:cWUSDC settlement chain138 0xf22258f57794CC8E06237084b353Ab30fFfa640b +-}`; + +describe('core wiring defaults', () => { + beforeEach(() => { + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + }); + it('enables FIN TCP, OMNL forward, and intake gateway in config', () => { + const config = loadListenerConfig(ROOT); + expect(config.adapters.swiftFinTcp.enabled).toBe(true); + expect(config.outbound?.omnlForward.enabled).toBe(true); + expect(config.outbound?.intakeGateway.enabled).toBe(true); + expect(config.outbound?.omnlForward.apiPath).toBe(OMNL_ISO20022_API_PATH); + }); +}); + +describe('OMNL POST /api/v1/omnl/iso20022/messages', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ id: 'omnl-1' }), { status: 201 })) + ); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllGlobals(); + }); + + it('POSTs matched event to token-aggregation OMNL ISO store', async () => { + const config = loadListenerConfig(ROOT); + const parsed = parseSwiftFin(SAMPLE_MT103); + const canonical = mapToCanonical(parsed, 'wire-test-key')!; + const event: SwiftListenerEvent = { + id: 'evt-wire', + receivedAt: new Date().toISOString(), + source: 'test', + format: 'swift_fin', + messageType: 'MT103', + matched: true, + matches: [{ kind: 'cw_symbol', value: 'cWUSDC', field: 'test' }], + parsed: parsed.parsed, + canonical, + rawPreview: SAMPLE_MT103, + dedupeKey: 'wire-test-key', + }; + + const result = await forwardToOmnl(ROOT, config, event); + expect(result.apiPath).toBe('/api/v1/omnl/iso20022/messages'); + expect(result.apiStatus).toBe(201); + + const fetchMock = global.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/v1/omnl/iso20022/messages'); + expect(init.method).toBe('POST'); + const body = JSON.parse(String(init.body)); + expect(body.messageType).toBe('pacs.008'); + expect(body.instructionId).toContain('CHAIN138'); + }); +}); + +describe('ISO20022IntakeGateway on match', () => { + beforeEach(() => { + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ id: 'omnl-gw' }), { status: 201 })) + ); + }); + + afterEach(() => { + delete process.env.PRIVATE_KEY; + delete process.env.SWIFT_LISTENER_GATEWAY_EXECUTE; + vi.unstubAllGlobals(); + }); + + it('dispatches intake gateway (prepared dry-run without key)', async () => { + const config = loadListenerConfig(ROOT); + const registry = buildResourceRegistry(ROOT, config); + const parsed = parseSwiftFin(SAMPLE_MT103); + const canonical = mapToCanonical(parsed, 'gateway-key')!; + const event: SwiftListenerEvent = { + id: 'evt-gw', + receivedAt: new Date().toISOString(), + source: 'test', + format: 'swift_fin', + messageType: 'MT103', + matched: true, + matches: [], + parsed: parsed.parsed, + canonical, + rawPreview: SAMPLE_MT103, + dedupeKey: 'gateway-key', + }; + + const outbound = await dispatchMatchedOutbounds(ROOT, config, registry, event); + expect(outbound?.intakeGateway).toBeDefined(); + expect(outbound?.intakeGateway?.gateway).toBe('0x66C89F91A4e830d87fcE4E5F86aFA71A96FbDa3d'); + expect(outbound?.intakeGateway?.dryRun).toBe(true); + expect(outbound?.intakeGateway?.prepared).toBe(true); + }); +}); + +describe('SWIFT FIN TCP feed', () => { + it('is enabled and extracts FIN frames for TCP ingestion', async () => { + const config = loadListenerConfig(ROOT); + expect(config.adapters.swiftFinTcp.enabled).toBe(true); + + const { extractFinMessages } = await import('../src/adapters/swiftFinTcpAdapter'); + const { messages } = extractFinMessages(SAMPLE_MT103); + expect(messages.length).toBe(1); + expect(messages[0]).toContain('CHAIN138-wire-test'); + }); +}); + +describe('processMessage end-to-end wiring', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + delete process.env.PRIVATE_KEY; + process.env.SWIFT_LISTENER_GATEWAY_EXECUTE = '0'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ id: 'omnl-2' }), { status: 201 })) + ); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllGlobals(); + }); + + it('runs OMNL forward + intake gateway on matched message', async () => { + const listener = createSwiftListener(ROOT); + const event = await listener.processMessage({ + source: `wiring-e2e:${Date.now()}`, + format: 'swift_fin', + payload: SAMPLE_MT103, + }); + + expect(event?.matched).toBe(true); + expect(event?.outbound?.omnl?.apiPath).toBe('/api/v1/omnl/iso20022/messages'); + expect(event?.outbound?.omnl?.apiStatus).toBe(201); + expect(event?.outbound?.intakeGateway?.gateway).toBeTruthy(); + }); +}); diff --git a/services/swift-listener/tsconfig.json b/services/swift-listener/tsconfig.json new file mode 100644 index 0000000..88da149 --- /dev/null +++ b/services/swift-listener/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/services/token-aggregation/package.json b/services/token-aggregation/package.json index 4d651dd..84c292e 100644 --- a/services/token-aggregation/package.json +++ b/services/token-aggregation/package.json @@ -22,6 +22,7 @@ "omnl:reconcile": "node scripts/omnl-reconcile-report.mjs" }, "dependencies": { + "@dbis/integration-foundation": "file:../../packages/integration-foundation", "axios": "^1.15.2", "bcrypt": "^6.0.0", "compression": "^1.8.1", diff --git a/services/token-aggregation/public/omnl-settlement-terminal.css b/services/token-aggregation/public/omnl-settlement-terminal.css new file mode 100644 index 0000000..74a8da0 --- /dev/null +++ b/services/token-aggregation/public/omnl-settlement-terminal.css @@ -0,0 +1,190 @@ +:root { + --font-mono: "Consolas", "Lucida Console", "Courier New", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: system-ui, sans-serif; +} + +body.theme-black { + background: #0a0a0a; + color: #33ff66; +} + +body.theme-color { + background: #d8d8d8; + color: #111; +} + +.layout { + max-width: 1200px; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; +} + +.topbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +h1 { + font-size: 1.15rem; + margin: 0 0 0.25rem; +} + +.sub { + margin: 0; + opacity: 0.85; + font-size: 0.85rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button, +.btn { + font: inherit; + cursor: pointer; + border: 1px solid currentColor; + background: transparent; + color: inherit; + padding: 0.35rem 0.65rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.85rem; +} + +button.active { + font-weight: 700; +} + +.card { + border: 1px solid currentColor; + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 1rem; +} + +.params { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.75rem; +} + +.params label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.params input { + font: inherit; + padding: 0.35rem 0.5rem; + border: 1px solid currentColor; + background: transparent; + color: inherit; + border-radius: 4px; +} + +.main { + display: grid; + grid-template-columns: 1fr min(22rem, 100%); + gap: 1rem; +} + +@media (max-width: 900px) { + .main { + grid-template-columns: 1fr; + } +} + +.terminal { + margin: 0; + min-height: 28rem; + max-height: 70vh; + overflow: auto; + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.35; + white-space: pre-wrap; + word-break: break-word; +} + +body.theme-black .terminal { + background: #000; + color: #33ff66; + padding: 1rem; +} + +body.theme-color .terminal { + background: #ececec; + color: #111; + padding: 1rem; +} + +.side h2 { + font-size: 0.85rem; + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cmd-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.alchemy-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; + margin-bottom: 0.75rem; +} + +.alchemy-row input { + font: inherit; + padding: 0.35rem; + border: 1px solid currentColor; + background: transparent; + color: inherit; + border-radius: 4px; +} + +.json-out { + margin: 0; + max-height: 16rem; + overflow: auto; + font-family: var(--font-mono); + font-size: 0.7rem; + white-space: pre-wrap; + word-break: break-word; + opacity: 0.9; +} + +footer { + font-size: 0.75rem; + opacity: 0.8; +} + +footer code { + font-family: var(--font-mono); +} diff --git a/services/token-aggregation/public/omnl-settlement-terminal.html b/services/token-aggregation/public/omnl-settlement-terminal.html new file mode 100644 index 0000000..cfe56b5 --- /dev/null +++ b/services/token-aggregation/public/omnl-settlement-terminal.html @@ -0,0 +1,75 @@ + + + + + + + OMNL / HYBX Settlement Terminal + + + +
+
+
+

OMNL / HYBX Settlement Terminal

+

Fineract SoR · Alchemy on-chain · institutional document API

+
+
+ + + + Compliance + Status JSON +
+
+ +
+ + + + + + +
+ +
+
+
Loading…
+
+ +
+ +
+

+ API: /api/v1/omnl/terminal/* · Documents bound to Fineract — not SWIFT NET transmission. + Pass ?access_token= or Bearer when OMNL_API_KEY is set. +

+
+
+ + + diff --git a/services/token-aggregation/public/omnl-settlement-terminal.js b/services/token-aggregation/public/omnl-settlement-terminal.js new file mode 100644 index 0000000..d2392e4 --- /dev/null +++ b/services/token-aggregation/public/omnl-settlement-terminal.js @@ -0,0 +1,217 @@ +(function () { + const API_BASE = document.querySelector('meta[name="omnl-api-base"]')?.getAttribute('content') || '/api/v1'; + const screenEl = document.getElementById('terminal-screen'); + const jsonEl = document.getElementById('json-out'); + const inpRef = document.getElementById('inp-settlement-ref'); + const inpOffice = document.getElementById('inp-office-id'); + const inpDate = document.getElementById('inp-value-date'); + const inpAmount = document.getElementById('inp-amount'); + const inpToken = document.getElementById('inp-token'); + const inpAlchemyAddr = document.getElementById('inp-alchemy-addr'); + + let screenMode = 'black'; + + const inpConnection = document.getElementById('inp-connection'); + const DEFAULT_CONNECTION = 'mk-albert-gate-limited'; + const DEFAULT_WALLET = '0xb3B416BdE671c256aAbFB56358baD91D46BB9c39'; + + (function initFromQuery() { + const p = new URLSearchParams(window.location.search); + const conn = p.get('connectionId') || DEFAULT_CONNECTION; + if (inpConnection) inpConnection.value = conn; + const ref = p.get('settlementRef'); + if (ref) inpRef.value = ref; + })(); + + function qs() { + const p = new URLSearchParams(window.location.search); + const token = inpToken.value.trim() || p.get('access_token') || ''; + const q = new URLSearchParams({ + settlementRef: inpRef.value.trim(), + officeId: String(inpOffice.value || '1'), + valueDate: inpDate.value, + amountUsd: inpAmount.value.trim(), + }); + const conn = inpConnection && inpConnection.value ? inpConnection.value.trim() : ''; + if (conn) q.set('connectionId', conn); + if (token) q.set('access_token', token); + return q; + } + + function authHeaders() { + const token = inpToken.value.trim() || new URLSearchParams(window.location.search).get('access_token') || ''; + return token ? { Authorization: 'Bearer ' + token } : {}; + } + + async function apiGet(path, extra) { + const q = qs(); + if (extra) { + Object.entries(extra).forEach(function (kv) { + q.set(kv[0], kv[1]); + }); + } + const res = await fetch(API_BASE + path + '?' + q.toString(), { headers: authHeaders() }); + const text = await res.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = { raw: text, status: res.status }; + } + if (!res.ok) throw new Error(data.error || data.raw || res.statusText); + return data; + } + + async function apiPost(path, body) { + const q = qs(); + const res = await fetch(API_BASE + path + '?' + q.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; + } + + function showJson(data) { + jsonEl.textContent = JSON.stringify(data, null, 2); + } + + function showScreen(text) { + screenEl.textContent = text; + } + + function setTheme(mode) { + screenMode = mode === 'color' ? 'color' : 'black'; + document.body.classList.toggle('theme-black', screenMode === 'black'); + document.body.classList.toggle('theme-color', screenMode === 'color'); + document.getElementById('btn-mode-black').classList.toggle('active', screenMode === 'black'); + document.getElementById('btn-mode-color').classList.toggle('active', screenMode === 'color'); + } + + async function loadScreen(artifact) { + const data = await apiGet('/omnl/terminal/screen', { + mode: screenMode, + artifact: artifact || 'package', + format: 'json', + }); + showScreen(data.text || ''); + showJson(data); + } + + async function runCmd(cmd) { + try { + if (cmd === 'help') { + showScreen( + [ + 'OMNL / HYBX SETTLEMENT TERMINAL — COMMANDS', + '', + ' status — integration status', + ' package — full JSON package', + ' pof — proof of funds', + ' debit-note — debit note', + ' remittance — remittance advice', + ' token-san — tokenization sanitized copy', + ' swift-copy — conversion SWIFT copy', + ' screen — terminal screen (current theme)', + ' alchemy balance — eth_getBalance via Alchemy', + '', + 'Toggle Black/Green vs Gray/Black with toolbar buttons.', + ].join('\n') + ); + return; + } + if (cmd === 'status') { + const q = qs(); + const token = q.get('access_token'); + const url = API_BASE + '/omnl/terminal/status' + (token ? '?access_token=' + encodeURIComponent(token) : ''); + const data = await fetch(url, { headers: authHeaders() }).then(function (r) { + return r.json(); + }); + showJson(data); + showScreen(JSON.stringify(data, null, 2)); + return; + } + if (cmd === 'package') { + const data = await apiGet('/omnl/terminal/package'); + showJson(data); + if (data.artifacts && data.artifacts.screens) { + showScreen(data.artifacts.screens[screenMode] || ''); + } + return; + } + if (cmd === 'pof') { + const data = await apiGet('/omnl/terminal/proof-of-funds'); + showJson(data); + await loadScreen('proof-of-funds'); + return; + } + if (cmd === 'debit') { + const data = await apiGet('/omnl/terminal/debit-note'); + showJson(data); + await loadScreen('debit-note'); + return; + } + if (cmd === 'remittance') { + const data = await apiGet('/omnl/terminal/remittance-advice'); + showJson(data); + await loadScreen('remittance-advice'); + return; + } + if (cmd === 'token') { + const data = await apiGet('/omnl/terminal/tokenization-sanitized'); + showJson(data); + await loadScreen('tokenization-sanitized'); + return; + } + if (cmd === 'swift') { + const data = await apiGet('/omnl/terminal/conversion-swift-copy'); + showJson(data); + await loadScreen('conversion-swift-copy'); + return; + } + if (cmd === 'screen') { + await loadScreen('package'); + return; + } + if (cmd === 'alchemy-balance') { + const addr = (inpAlchemyAddr.value || '').trim(); + if (!addr) throw new Error('Set Alchemy address first'); + const data = await apiPost('/omnl/terminal/alchemy/rpc', { + network: 'eth-mainnet', + method: 'eth_getBalance', + params: [addr, 'latest'], + connectionId: (inpConnection && inpConnection.value) || DEFAULT_CONNECTION, + }); + showJson(data); + showScreen('ALCHEMY eth_getBalance\nADDRESS: ' + addr + '\nRESULT: ' + data.result); + return; + } + } catch (e) { + showScreen('ERROR: ' + (e && e.message ? e.message : String(e))); + } + } + + document.getElementById('btn-mode-black').addEventListener('click', function () { + setTheme('black'); + runCmd('screen'); + }); + document.getElementById('btn-mode-color').addEventListener('click', function () { + setTheme('color'); + runCmd('screen'); + }); + document.getElementById('btn-refresh').addEventListener('click', function () { + runCmd('screen'); + }); + document.querySelectorAll('[data-cmd]').forEach(function (btn) { + btn.addEventListener('click', function () { + runCmd(btn.getAttribute('data-cmd')); + }); + }); + + setTheme('black'); + runCmd('screen'); +})(); diff --git a/services/token-aggregation/public/reserve-dashboard.css b/services/token-aggregation/public/reserve-dashboard.css new file mode 100644 index 0000000..0715470 --- /dev/null +++ b/services/token-aggregation/public/reserve-dashboard.css @@ -0,0 +1,106 @@ +:root { + --bg: #0b0f14; + --card: #121a24; + --border: #1e2a3a; + --text: #e8eef6; + --muted: #8fa3bc; + --pass: #22c55e; + --fail: #ef4444; + --warn: #f59e0b; + --skip: #64748b; + --accent: #38bdf8; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.wrap { max-width: 72rem; margin: 0 auto; padding: 1.25rem; } +header { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} +h1 { margin: 0 0 0.25rem; font-size: 1.5rem; } +.sub { color: var(--muted); margin: 0; font-size: 0.9rem; } +.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; } +button, .btn { + background: var(--card); + border: 1px solid var(--border); + color: var(--text); + padding: 0.45rem 0.85rem; + border-radius: 0.375rem; + cursor: pointer; + text-decoration: none; + font-size: 0.875rem; +} +button.primary, .btn.primary { background: #0ea5e9; border-color: #0284c7; color: #041018; font-weight: 600; } +.status-bar { + padding: 0.65rem 0.85rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + margin-bottom: 1rem; + font-size: 0.9rem; +} +.status-bar.pass { border-color: #166534; background: #052e1622; } +.status-bar.fail { border-color: #991b1b; background: #450a0a33; } +.status-bar.loading { border-color: var(--border); color: var(--muted); } +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); +} +.section { margin-bottom: 1.25rem; } +.section h2 { font-size: 1rem; margin: 0 0 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; +} +.metric .label { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; } +.metric .value { font-size: 1.35rem; font-weight: 600; margin-top: 0.15rem; word-break: break-word; } +.metric .value.small { font-size: 1rem; } +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} +.badge.pass { background: #14532d; color: #bbf7d0; } +.badge.fail { background: #7f1d1d; color: #fecaca; } +.badge.stale { background: #78350f; color: #fde68a; } +.badge.skip { background: #334155; color: #cbd5e1; } +.proof-row { + display: grid; + grid-template-columns: 3rem 1fr auto; + gap: 0.75rem; + align-items: center; + padding: 0.65rem 0; + border-bottom: 1px solid var(--border); +} +.proof-row:last-child { border-bottom: none; } +.proof-id { font-weight: 700; color: var(--accent); } +.proof-detail { color: var(--muted); font-size: 0.85rem; } +footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; } +footer a { color: var(--accent); } +.raw { background: #05080c; padding: 1rem; overflow: auto; font-size: 0.75rem; border-radius: 0.375rem; max-height: 24rem; } +.hidden { display: none; } +.institutional-banner { + background: linear-gradient(90deg, #0c4a6e, #164e63); + border: 1px solid #155e75; + border-radius: 0.5rem; + padding: 0.85rem 1rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} diff --git a/services/token-aggregation/public/reserve-dashboard.html b/services/token-aggregation/public/reserve-dashboard.html new file mode 100644 index 0000000..bc7db2e --- /dev/null +++ b/services/token-aggregation/public/reserve-dashboard.html @@ -0,0 +1,69 @@ + + + + + + + GRU Reserve Capacity — Live Proof + + + +
+
+
+

GRU Reserve Capacity

+

Semi-public live attestation · ≥3 independent proof points · Chain 138

+
+
+ + + Proof index API + Compliance console +
+
+ +
Initializing…
+

+ +
+

Proof quorum

+
+
+ +
+

Capacity

+
+
+ +
+

Proof points (P3 · P4 · P6 minimum set)

+
+
+ +
+
+

On-chain gate

+
+
+
+

Triple-state reconcile

+
+
+
+ + + + +
+ + + diff --git a/services/token-aggregation/public/reserve-dashboard.js b/services/token-aggregation/public/reserve-dashboard.js new file mode 100644 index 0000000..784dc46 --- /dev/null +++ b/services/token-aggregation/public/reserve-dashboard.js @@ -0,0 +1,115 @@ +(function () { + const meta = document.querySelector('meta[name="reserve-api-base"]'); + const apiBase = (meta && meta.content) || '/api/v1'; + const institutional = document.body.dataset.institutional === '1'; + + const els = { + status: document.getElementById('load-status'), + refreshed: document.getElementById('refreshed-at'), + quorum: document.getElementById('quorum-summary'), + metrics: document.getElementById('metrics-grid'), + proof: document.getElementById('proof-points'), + gate: document.getElementById('gate-kv'), + triple: document.getElementById('triple-kv'), + raw: document.getElementById('raw-json'), + rawSection: document.getElementById('raw-section'), + }; + + function fmtUsd(n) { + if (n == null || Number.isNaN(n)) return '—'; + if (n >= 1e12) return `$${(n / 1e12).toFixed(3)}T`; + if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; + if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`; + return `$${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + } + + function fmtBps(bps) { + if (bps == null) return '—'; + const pct = Number(bps) / 100; + return `${pct.toFixed(2)}% (${bps} bps)`; + } + + function badge(status) { + const s = status || 'skip'; + return `${s}`; + } + + function kv(rows) { + return rows + .map(([k, v]) => `
${k}
${v}
`) + .join(''); + } + + async function load(refresh) { + els.status.className = 'status-bar loading'; + els.status.textContent = 'Loading reserve capacity…'; + try { + const url = `${apiBase}/reserve/capacity${refresh ? '?refresh=1' : ''}`; + const res = await fetch(url, { cache: 'no-store' }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + + const q = data.proofQuorum || {}; + const cap = data.capacity || {}; + const passed = !!q.passed; + + els.status.className = `status-bar ${passed ? 'pass' : 'fail'}`; + els.status.textContent = passed + ? `Proof quorum PASS (${q.passCount}/${q.requiredCount}) — semi-public live attestation` + : `Proof quorum PARTIAL (${q.passCount}/${q.requiredCount}) — see proof points below`; + + els.refreshed.textContent = `Snapshot: ${data.generatedAt} · SLA freshness ≤ ${data.sla?.freshnessMaxAgeSeconds ?? 60}s`; + + els.quorum.innerHTML = kv([ + ['Minimum set', data.minimumSet || 'reserveCapacityLive'], + ['Quorum', `${q.passCount ?? 0} / ${q.requiredCount ?? 3} (need ${q.minimumRequired ?? 3})`], + ['Issuance', cap.issuanceBlocked ? 'BLOCKED' : 'Allowed'], + ['Coverage', fmtBps(cap.coverageRatioBps)], + ]); + + els.metrics.innerHTML = kv([ + ['Physical reserve (USD)', fmtUsd(cap.reserveUsd)], + ['M1 c* circulating', fmtUsd(cap.m1Usd)], + ['M00 Li* notional', fmtUsd(cap.m00Usd)], + ['Velocity zone', cap.velocityZone || '—'], + ['Block reasons', (cap.issuanceBlockReasons || []).join(', ') || 'none'], + ]); + + const points = q.points || {}; + els.proof.innerHTML = Object.values(points) + .map((p) => `
${p.id}
${p.name || ''}
${p.detail || ''}
${badge(p.status)}
`) + .join('') || '

No proof points in response.

'; + + const g = data.onChainGate || {}; + els.gate.innerHTML = kv([ + ['Gate address', g.address ? `${g.address}` : '—'], + ['RPC reachable', g.rpcReachable ? 'yes' : 'no'], + ['Coverage (on-chain)', g.coverageRatioBps != null ? fmtBps(g.coverageRatioBps) : '—'], + ['M1/M00 util', g.m1ToM00Utilization != null ? String(g.m1ToM00Utilization) : '—'], + ['Issuance paused', g.issuancePaused == null ? '—' : String(g.issuancePaused)], + ]); + + const t = data.tripleState || {}; + els.triple.innerHTML = kv([ + ['Aligned', t.aligned == null ? '—' : String(t.aligned)], + ['Breaks', t.breaksCount != null ? String(t.breaksCount) : '—'], + ['As of', t.generatedAt || '—'], + ]); + + els.raw.textContent = JSON.stringify(data, null, 2); + } catch (e) { + els.status.className = 'status-bar fail'; + els.status.textContent = `Error: ${e.message || e}`; + } + } + + document.getElementById('btn-refresh')?.addEventListener('click', () => load(true)); + document.getElementById('btn-toggle-raw')?.addEventListener('click', () => { + els.rawSection?.classList.toggle('hidden'); + }); + + if (institutional) { + setInterval(() => load(false), 30000); + } + load(false); +})(); diff --git a/services/token-aggregation/public/reserve-institutional.html b/services/token-aggregation/public/reserve-institutional.html new file mode 100644 index 0000000..bbf9921 --- /dev/null +++ b/services/token-aggregation/public/reserve-institutional.html @@ -0,0 +1,74 @@ + + + + + + + OMNL Institutional Reserve Transparency + + + +
+
+ Institutional disclosure. Semi-public reserve capacity and multi-point cryptographic proof. + Full regulatory books and custodian detail available under NDA. Single source of truth: + explorer.d-bis.org API (no duplicate math). +
+
+
+

OMNL · GRU Reserve Transparency

+

Defi Oracle Meta Mainnet (138) · HYBX institutional ledger cross-check · 99.999% SLA target

+
+
+ + + Explorer dashboard + Compliance console +
+
+ +
Initializing…
+

+ +
+

Proof quorum

+
+
+ +
+

Capacity (redacted semi-public)

+
+
+ +
+

Independent proof points

+
+
+ +
+
+

On-chain policy gate

+
+
+
+

Triple-state reconcile

+
+
+
+ + + + +
+ + + diff --git a/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts index 39f706c..557cc28 100644 --- a/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts +++ b/services/token-aggregation/src/api/middleware/omnl-audit-middleware.ts @@ -1,11 +1,17 @@ import type { Request, Response, NextFunction } from 'express'; -import { randomUUID } from 'crypto'; +import { createCorrelationContext, CORRELATION_ID_HEADER, REQUEST_ID_HEADER } from '@dbis/integration-foundation'; import { appendOmnlAudit } from '../../services/omnl-audit-log'; -/** Attach trace id and log every /omnl request (response status on finish). */ +/** Attach trace/correlation ids and log every /omnl request (response status on finish). */ export function omnlAuditMiddleware(req: Request, res: Response, next: NextFunction): void { - const traceId = (req.headers['x-omnl-trace-id'] as string) || randomUUID(); + const ctx = createCorrelationContext({ + correlationId: String(req.headers[CORRELATION_ID_HEADER] ?? req.headers['x-omnl-trace-id'] ?? ''), + requestId: String(req.headers[REQUEST_ID_HEADER] ?? ''), + }); + const traceId = ctx.correlationId; res.setHeader('X-OMNL-Trace-Id', traceId); + res.setHeader(CORRELATION_ID_HEADER, ctx.correlationId); + res.setHeader(REQUEST_ID_HEADER, ctx.requestId); const started = Date.now(); res.on('finish', () => { appendOmnlAudit({ diff --git a/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts b/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts new file mode 100644 index 0000000..6accbbf --- /dev/null +++ b/services/token-aggregation/src/api/middleware/omnl-compliance-gate.ts @@ -0,0 +1,79 @@ +import type { Request, Response, NextFunction } from 'express'; +import { MockComplianceDecisionEngine } from '@dbis/integration-foundation'; + +const engine = new MockComplianceDecisionEngine(); + +const FINANCIAL_POST_PATHS = [ + '/omnl/iso20022/messages', +]; + +/** + * Optional compliance decision gate for financial POST routes. + * Enable with OMNL_COMPLIANCE_GATE=1 (uses mock engine until provider integration). + */ +export async function omnlComplianceGateMiddleware( + req: Request, + res: Response, + next: NextFunction +): Promise { + if (process.env.OMNL_COMPLIANCE_GATE !== '1') { + next(); + return; + } + if (req.method !== 'POST') { + next(); + return; + } + const path = req.path.replace(/^\/api\/v1/, ''); + if (!FINANCIAL_POST_PATHS.some((p) => path.endsWith(p))) { + next(); + return; + } + + const body = req.body as { + messageType?: string; + payload?: string; + entityId?: string; + tenantId?: string; + amount?: string; + currency?: string; + riskHints?: string[]; + }; + + const result = await engine.evaluateTransaction({ + entityId: body.entityId ?? 'omnl-default', + tenantId: body.tenantId ?? 'omnl', + amount: body.amount ?? '0', + currency: body.currency ?? 'USD', + transactionType: body.messageType ?? 'iso20022.store', + riskHints: body.riskHints, + }); + + res.setHeader('X-OMNL-Compliance-Decision', result.decision); + res.setHeader('X-OMNL-Audit-Event-Id', result.audit_event_id); + + if (result.decision === 'block' || result.decision === 'reject') { + res.status(403).json({ + error: 'compliance_blocked', + decision: result.decision, + reason_codes: result.reason_codes, + audit_event_id: result.audit_event_id, + manual_review_required: result.manual_review_required, + }); + return; + } + + if (result.decision === 'hold' || result.decision === 'review') { + res.status(202).json({ + status: 'pending_review', + decision: result.decision, + reason_codes: result.reason_codes, + audit_event_id: result.audit_event_id, + manual_review_required: true, + }); + return; + } + + (req as Request & { complianceAuditId?: string }).complianceAuditId = result.audit_event_id; + next(); +} diff --git a/services/token-aggregation/src/api/middleware/omnl-compliance-idempotency.test.ts b/services/token-aggregation/src/api/middleware/omnl-compliance-idempotency.test.ts new file mode 100644 index 0000000..96da073 --- /dev/null +++ b/services/token-aggregation/src/api/middleware/omnl-compliance-idempotency.test.ts @@ -0,0 +1,91 @@ +import type { Request, Response } from 'express'; +import { omnlComplianceGateMiddleware } from './omnl-compliance-gate'; +import { omnlIdempotencyMiddleware } from './omnl-idempotency'; + +function mockRes(): Response & { statusCode?: number; body?: unknown; headers: Record } { + const headers: Record = {}; + const res = { + headers, + statusCode: 200, + body: undefined as unknown, + setHeader(name: string, value: string) { + headers[name.toLowerCase()] = value; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; + return res as Response & typeof res; +} + +function mockReq(overrides: Partial & { path?: string; method?: string } = {}): Request { + return { + method: 'POST', + path: '/api/v1/omnl/iso20022/messages', + headers: {}, + body: { messageType: 'pacs.008', payload: '' }, + ...overrides, + } as Request; +} + +describe('omnlComplianceGateMiddleware', () => { + const oldGate = process.env.OMNL_COMPLIANCE_GATE; + + afterEach(() => { + if (oldGate === undefined) delete process.env.OMNL_COMPLIANCE_GATE; + else process.env.OMNL_COMPLIANCE_GATE = oldGate; + }); + + it('passes through when OMNL_COMPLIANCE_GATE is unset', async () => { + delete process.env.OMNL_COMPLIANCE_GATE; + const next = jest.fn(); + await omnlComplianceGateMiddleware(mockReq(), mockRes(), next); + expect(next).toHaveBeenCalled(); + }); + + it('sets compliance headers and passes when gate enabled', async () => { + process.env.OMNL_COMPLIANCE_GATE = '1'; + const next = jest.fn(); + const res = mockRes(); + await omnlComplianceGateMiddleware(mockReq(), res, next); + expect(res.headers['x-omnl-compliance-decision']).toBeDefined(); + expect(res.headers['x-omnl-audit-event-id']).toBeDefined(); + expect(next).toHaveBeenCalled(); + }); + + it('skips non-POST methods', async () => { + process.env.OMNL_COMPLIANCE_GATE = '1'; + const next = jest.fn(); + await omnlComplianceGateMiddleware(mockReq({ method: 'GET' }), mockRes(), next); + expect(next).toHaveBeenCalled(); + }); +}); + +describe('omnlIdempotencyMiddleware', () => { + it('passes when X-Idempotency-Key is absent', () => { + const next = jest.fn(); + omnlIdempotencyMiddleware(mockReq(), mockRes(), next); + expect(next).toHaveBeenCalled(); + }); + + it('returns 409 on duplicate idempotency key', () => { + const req = mockReq({ + headers: { 'x-idempotency-key': 'idem-test-duplicate-key' }, + }); + const next = jest.fn(); + omnlIdempotencyMiddleware(req, mockRes(), next); + expect(next).toHaveBeenCalledTimes(1); + + const res = mockRes(); + const next2 = jest.fn(); + omnlIdempotencyMiddleware(req, res, next2); + expect(res.statusCode).toBe(409); + expect(next2).not.toHaveBeenCalled(); + expect(res.body).toMatchObject({ error: 'duplicate_idempotency_key' }); + }); +}); diff --git a/services/token-aggregation/src/api/middleware/omnl-idempotency.ts b/services/token-aggregation/src/api/middleware/omnl-idempotency.ts new file mode 100644 index 0000000..845a5a8 --- /dev/null +++ b/services/token-aggregation/src/api/middleware/omnl-idempotency.ts @@ -0,0 +1,41 @@ +import type { Request, Response, NextFunction } from 'express'; +import { deriveIdempotencyKey, IdempotencyStore } from '@dbis/integration-foundation'; + +const store = new IdempotencyStore(); + +const IDEMPOTENT_PATHS = ['/omnl/iso20022/messages']; + +/** + * Dedup financial POSTs when X-Idempotency-Key header is present. + */ +export function omnlIdempotencyMiddleware(req: Request, res: Response, next: NextFunction): void { + if (req.method !== 'POST') { + next(); + return; + } + const path = req.path.replace(/^\/api\/v1/, ''); + if (!IDEMPOTENT_PATHS.some((p) => path.endsWith(p))) { + next(); + return; + } + + const headerKey = String(req.headers['x-idempotency-key'] ?? '').trim(); + if (!headerKey) { + next(); + return; + } + + const key = deriveIdempotencyKey({ + tenantId: String(req.headers['x-tenant-id'] ?? 'omnl'), + entityId: String(req.headers['x-entity-id'] ?? 'default'), + operation: path, + resourceId: headerKey, + }); + + if (!store.record(key)) { + res.status(409).json({ error: 'duplicate_idempotency_key', idempotencyKey: headerKey }); + return; + } + + next(); +} diff --git a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts index 28423b6..751d64e 100644 --- a/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts +++ b/services/token-aggregation/src/api/routes/omnl-compliance-routes.ts @@ -2,6 +2,8 @@ import { Router, Request, Response } from 'express'; import { omnlRateLimiter } from '../middleware/rate-limit'; import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; +import { omnlComplianceGateMiddleware } from '../middleware/omnl-compliance-gate'; +import { omnlIdempotencyMiddleware } from '../middleware/omnl-idempotency'; import { runTripleStateReconcile } from '../../services/omnl-triple-reconcile'; import { buildIfrs7Disclosure, @@ -31,6 +33,8 @@ const router = Router(); router.use(omnlRateLimiter); router.use(omnlAuditMiddleware); router.use(omnlRequireApiKeyInProduction); +router.use(omnlIdempotencyMiddleware); +router.use(omnlComplianceGateMiddleware); router.get('/omnl/reconcile/triple-state', omnlSensitiveRouteGuard, async (req: Request, res: Response) => { try { diff --git a/services/token-aggregation/src/api/routes/omnl-terminal-routes.ts b/services/token-aggregation/src/api/routes/omnl-terminal-routes.ts new file mode 100644 index 0000000..fc1d50d --- /dev/null +++ b/services/token-aggregation/src/api/routes/omnl-terminal-routes.ts @@ -0,0 +1,278 @@ +import { Router, Request, Response } from 'express'; +import { omnlRateLimiter } from '../middleware/rate-limit'; +import { omnlSensitiveRouteGuard, omnlRequireApiKeyInProduction } from '../middleware/omnl-guards'; +import { omnlAuditMiddleware } from '../middleware/omnl-audit-middleware'; +import { + buildSettlementTerminalContext, + buildFullTerminalPackage, + getTerminalArtifact, + renderTerminalScreen, + type TerminalArtifact, + type TerminalScreenMode, +} from '../../services/omnl-settlement-terminal'; +import { + listTerminalConnectionIds, + loadTerminalConnection, + buildConnectionPublicSummary, + connectionAlchemyApiKey, +} from '../../services/omnl-terminal-connection'; +import { + alchemyJsonRpc, + alchemyTokenPricesByAddress, + alchemyPrepareErc20Transfer, + alchemyConfigured, + type AlchemyNetwork, +} from '../../services/omnl-alchemy-client'; +import { appendOmnlAudit } from '../../services/omnl-audit-log'; + +const router = Router(); +router.use(omnlRateLimiter); +router.use(omnlAuditMiddleware); +router.use(omnlRequireApiKeyInProduction); + +function parseQueryContext(req: Request): { + settlementRef?: string; + officeId?: number; + valueDate?: string; + amountUsd?: string; + connectionId?: string; +} { + const officeRaw = String(req.query.officeId ?? '').trim(); + return { + settlementRef: String(req.query.settlementRef ?? '').trim() || undefined, + officeId: officeRaw ? parseInt(officeRaw, 10) : undefined, + valueDate: String(req.query.valueDate ?? '').trim() || undefined, + amountUsd: String(req.query.amountUsd ?? '').trim() || undefined, + connectionId: String(req.query.connectionId ?? '').trim() || undefined, + }; +} + +function resolveAlchemyKey(connectionId?: string): string | undefined { + if (!connectionId) return undefined; + try { + const profile = loadTerminalConnection(connectionId); + const key = connectionAlchemyApiKey(profile); + return key || undefined; + } catch { + return undefined; + } +} + +function parseArtifact(raw: string): TerminalArtifact { + const v = raw.trim().toLowerCase(); + const allowed: TerminalArtifact[] = [ + 'package', + 'proof-of-funds', + 'debit-note', + 'remittance-advice', + 'tokenization-sanitized', + 'conversion-swift-copy', + ]; + return (allowed.includes(v as TerminalArtifact) ? v : 'package') as TerminalArtifact; +} + +async function withContext( + req: Request, + res: Response, + fn: (ctx: Awaited>) => void | Promise +): Promise { + try { + const ctx = await buildSettlementTerminalContext(parseQueryContext(req)); + await fn(ctx); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +} + +router.get('/omnl/terminal/status', omnlSensitiveRouteGuard, (req, res) => { + const connectionId = String(req.query.connectionId ?? '').trim() || undefined; + res.json({ + service: 'omnl-settlement-terminal', + version: '1.1.0', + alchemyConfigured: alchemyConfigured(resolveAlchemyKey(connectionId)), + fineractEnv: Boolean(process.env.OMNL_FINERACT_BASE_URL && process.env.OMNL_FINERACT_PASSWORD), + ui: '/omnl/terminal', + connections: listTerminalConnectionIds(), + activeConnectionId: connectionId, + }); +}); + +router.get('/omnl/terminal/connections', omnlSensitiveRouteGuard, (_req, res) => { + const ids = listTerminalConnectionIds(); + res.json({ + connections: ids.map((id) => { + try { + return buildConnectionPublicSummary(loadTerminalConnection(id)); + } catch (e) { + return { connectionId: id, error: e instanceof Error ? e.message : String(e) }; + } + }), + }); +}); + +router.get('/omnl/terminal/connections/:connectionId', omnlSensitiveRouteGuard, (req, res) => { + try { + const profile = loadTerminalConnection(String(req.params.connectionId)); + res.json(buildConnectionPublicSummary(profile)); + } catch (e) { + res.status(404).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +router.get('/omnl/terminal/package', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(buildFullTerminalPackage(ctx)); + }); +}); + +router.get('/omnl/terminal/proof-of-funds', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(getTerminalArtifact(ctx, 'proof-of-funds')); + }); +}); + +router.get('/omnl/terminal/debit-note', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(getTerminalArtifact(ctx, 'debit-note')); + }); +}); + +router.get('/omnl/terminal/remittance-advice', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(getTerminalArtifact(ctx, 'remittance-advice')); + }); +}); + +router.get('/omnl/terminal/tokenization-sanitized', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(getTerminalArtifact(ctx, 'tokenization-sanitized')); + }); +}); + +router.get('/omnl/terminal/conversion-swift-copy', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + res.json(getTerminalArtifact(ctx, 'conversion-swift-copy')); + }); +}); + +router.get('/omnl/terminal/screen', omnlSensitiveRouteGuard, async (req, res) => { + await withContext(req, res, (ctx) => { + const modeRaw = String(req.query.mode ?? 'black').trim().toLowerCase(); + const mode: TerminalScreenMode = modeRaw === 'color' ? 'color' : 'black'; + const artifact = parseArtifact(String(req.query.artifact ?? 'package')); + const text = renderTerminalScreen(ctx, mode, artifact); + const format = String(req.query.format ?? 'json').trim().toLowerCase(); + if (format === 'text' || format === 'plain') { + res.type('text/plain; charset=utf-8').send(text); + return; + } + res.json({ + mode, + artifact, + text, + context: ctx, + }); + }); +}); + +router.post('/omnl/terminal/alchemy/rpc', omnlSensitiveRouteGuard, async (req, res) => { + try { + const network = (String(req.body?.network ?? 'eth-mainnet').trim() || + 'eth-mainnet') as AlchemyNetwork; + const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim(); + const apiKey = resolveAlchemyKey(connectionId || undefined); + if (!alchemyConfigured(apiKey)) { + res.status(503).json({ error: 'Alchemy API key unset for connection' }); + return; + } + const method = String(req.body?.method ?? '').trim(); + const params = Array.isArray(req.body?.params) ? req.body.params : []; + if (!method) { + res.status(400).json({ error: 'JSON body must include method' }); + return; + } + const result = await alchemyJsonRpc(network, method, params, apiKey); + appendOmnlAudit({ + category: 'api', + action: 'alchemy_rpc', + metadata: { network, method }, + status: 'ok', + }); + res.json({ network, method, result }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +router.post('/omnl/terminal/alchemy/prices', omnlSensitiveRouteGuard, async (req, res) => { + try { + const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim(); + const apiKey = resolveAlchemyKey(connectionId || undefined); + if (!alchemyConfigured(apiKey)) { + res.status(503).json({ error: 'Alchemy API key unset for connection' }); + return; + } + const network = (String(req.body?.network ?? 'eth-mainnet').trim() || + 'eth-mainnet') as AlchemyNetwork; + const addresses = Array.isArray(req.body?.addresses) ? req.body.addresses.map(String) : []; + if (!addresses.length) { + res.status(400).json({ error: 'JSON body must include addresses: string[]' }); + return; + } + const data = await alchemyTokenPricesByAddress(network, addresses, apiKey); + appendOmnlAudit({ + category: 'api', + action: 'alchemy_prices', + metadata: { network, count: addresses.length }, + status: 'ok', + }); + res.json({ network, data }); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +router.post('/omnl/terminal/alchemy/tx-prep', omnlSensitiveRouteGuard, async (req, res) => { + try { + const connectionId = String(req.body?.connectionId ?? req.query.connectionId ?? '').trim(); + const apiKey = resolveAlchemyKey(connectionId || undefined); + if (!alchemyConfigured(apiKey)) { + res.status(503).json({ error: 'Alchemy API key unset for connection' }); + return; + } + const network = (String(req.body?.network ?? 'eth-mainnet').trim() || + 'eth-mainnet') as AlchemyNetwork; + const from = String(req.body?.from ?? '').trim(); + const to = String(req.body?.to ?? '').trim(); + const tokenAddress = String(req.body?.tokenAddress ?? '').trim(); + const amountHuman = String(req.body?.amount ?? req.body?.amountHuman ?? '').trim(); + const dryRun = req.body?.dryRun !== false; + if (!from || !to || !tokenAddress || !amountHuman) { + res.status(400).json({ + error: 'Required: from, to, tokenAddress, amount (human-readable token units)', + }); + return; + } + const prep = await alchemyPrepareErc20Transfer({ + network, + from, + to, + tokenAddress, + amountHuman, + dryRun, + apiKeyOverride: apiKey, + }); + appendOmnlAudit({ + category: 'api', + action: 'alchemy_tx_prep', + metadata: { network, from, to, tokenAddress, dryRun }, + status: 'ok', + }); + res.json(prep); + } catch (e) { + res.status(500).json({ error: e instanceof Error ? e.message : String(e) }); + } +}); + +export default router; diff --git a/services/token-aggregation/src/api/routes/reserve-capacity.ts b/services/token-aggregation/src/api/routes/reserve-capacity.ts new file mode 100644 index 0000000..b80f4c4 --- /dev/null +++ b/services/token-aggregation/src/api/routes/reserve-capacity.ts @@ -0,0 +1,55 @@ +import { Router, Request, Response } from 'express'; +import { cacheMiddleware } from '../middleware/cache'; +import { + buildProofIndex, + getLatestReserveCapacitySnapshot, + refreshReserveCapacityCache, +} from '../../services/gru-reserve-capacity'; + +const router = Router(); + +/** + * GET /reserve/capacity — semi-public live reserve capacity + multi-point proof quorum. + * Public (no OMNL API key). Cached 10s; ?refresh=1 forces recompute when live scheduler enabled. + */ +router.get('/reserve/capacity', cacheMiddleware(10 * 1000), async (req: Request, res: Response) => { + try { + const forceRefresh = req.query.refresh === '1'; + let snapshot = getLatestReserveCapacitySnapshot(); + const maxAgeSec = snapshot?.sla?.freshnessMaxAgeSeconds ?? 60; + const stale = + !snapshot || + (Date.now() - new Date(snapshot.generatedAt).getTime()) / 1000 > maxAgeSec; + + if (forceRefresh || stale) { + snapshot = await refreshReserveCapacityCache( + String(req.query.set || process.env.GRU_RESERVE_CAPACITY_MINIMUM_SET || 'reserveCapacityLive'), + ); + } + + if (!snapshot) { + res.status(503).json({ + error: 'Reserve capacity snapshot unavailable', + hint: 'Enable GRU_RESERVE_CAPACITY_LIVE=1 or run pnpm gru:reserve-capacity:collect', + }); + return; + } + + res.setHeader('Cache-Control', 'public, max-age=10, stale-while-revalidate=30'); + res.setHeader('X-GRU-Proof-Quorum', snapshot.proofQuorum.passed ? 'pass' : 'fail'); + res.status(200).json(snapshot); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + res.status(500).json({ error: msg }); + } +}); + +/** + * GET /reserve/proof-index — status of all configured proof points (P1–P7). + */ +router.get('/reserve/proof-index', cacheMiddleware(15 * 1000), (_req: Request, res: Response) => { + res.setHeader('Cache-Control', 'public, max-age=15'); + res.json(buildProofIndex()); +}); + +export default router; diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index c4d4669..fecb955 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -21,7 +21,9 @@ import plannerV2Routes from './routes/planner-v2'; import omnlRoutes from './routes/omnl'; import omnlIpsasRoutes from './routes/omnl-ipsas'; import omnlComplianceRoutes from './routes/omnl-compliance-routes'; +import omnlTerminalRoutes from './routes/omnl-terminal-routes'; import checkpointRoutes from './routes/checkpoint'; +import reserveCapacityRoutes from './routes/reserve-capacity'; import { MultiChainIndexer } from '../indexer/chain-indexer'; import { OmnlEventPoller } from '../indexer/omnl-event-poller'; import { getDatabasePool } from '../database/client'; @@ -120,12 +122,25 @@ export class ApiServer { } as const; this.app.use('/static', express.static(publicPath, staticOpts)); this.app.use('/omnl/static', express.static(publicPath, staticOpts)); + this.app.use('/reserve/static', express.static(publicPath, staticOpts)); } } private setupRoutes(): void { // Health check this.app.get('/health', async (req: Request, res: Response) => { + if (process.env.OMNL_SETTLEMENT_TERMINAL_ONLY === '1') { + res.json({ + status: 'healthy', + mode: 'omnl-settlement-terminal-only', + timestamp: new Date().toISOString(), + services: { + database: 'skipped', + indexer: 'disabled', + }, + }); + return; + } try { // Check database connection const pool = getDatabasePool(); @@ -150,6 +165,9 @@ export class ApiServer { const dashboardPath = path.join(__dirname, '../../public/omnl-dashboard.html'); const complianceConsolePath = path.join(__dirname, '../../public/omnl-compliance-console.html'); + const settlementTerminalPath = path.join(__dirname, '../../public/omnl-settlement-terminal.html'); + const reserveDashboardPath = path.join(__dirname, '../../public/reserve-dashboard.html'); + const reserveInstitutionalPath = path.join(__dirname, '../../public/reserve-institutional.html'); const authorizeOmnlHtml = (req: Request, res: Response): boolean => { const tok = process.env.OMNL_DASHBOARD_TOKEN?.trim(); @@ -181,6 +199,34 @@ export class ApiServer { res.type('html').send(readFileSync(dashboardPath, 'utf8')); }); + this.app.get('/omnl/terminal', (req: Request, res: Response) => { + if (!authorizeOmnlHtml(req, res)) return; + if (!existsSync(settlementTerminalPath)) { + res.status(404).type('text/plain').send('omnl-settlement-terminal.html missing'); + return; + } + res.type('html').send(readFileSync(settlementTerminalPath, 'utf8')); + }); + + /** Public semi-public reserve capacity dashboard (no auth). */ + this.app.get('/reserve', (_req: Request, res: Response) => { + if (!existsSync(reserveDashboardPath)) { + res.status(404).type('text/plain').send('reserve-dashboard.html missing'); + return; + } + res.type('html').send(readFileSync(reserveDashboardPath, 'utf8')); + }); + + /** Institutional wrapper (same API; used by reserve.d-bis.org NPM upstream). */ + this.app.get('/reserve/institutional', (_req: Request, res: Response) => { + const file = existsSync(reserveInstitutionalPath) ? reserveInstitutionalPath : reserveDashboardPath; + if (!existsSync(file)) { + res.status(404).type('text/plain').send('reserve dashboard missing'); + return; + } + res.type('html').send(readFileSync(file, 'utf8')); + }); + // Public API catalog (register before routers so GET /api/v1 is not swallowed by middleware-only mounts) const sendApiV1Catalog = (_req: Request, res: Response) => { res.json({ @@ -209,6 +255,9 @@ export class ApiServer { plannerRoutesPlan: '/api/v2/routes/plan', plannerIntentsPlan: '/api/v2/intents/plan', plannerInternalExecutionPlan: '/api/v2/routes/internal-execution-plan', + reserveCapacity: '/api/v1/reserve/capacity', + reserveProofIndex: '/api/v1/reserve/proof-index', + reserveDashboard: '/reserve', }, }); }; @@ -228,7 +277,9 @@ export class ApiServer { this.app.use('/api/v1', aggregatorRouteMatrixRoutes); this.app.use('/api/v1', partnerPayloadRoutes); this.app.use('/api/v1', checkpointRoutes); + this.app.use('/api/v1', reserveCapacityRoutes); this.app.use('/api/v1', omnlComplianceRoutes); + this.app.use('/api/v1', omnlTerminalRoutes); this.app.use('/api/v1', omnlRoutes); this.app.use('/api/v1', omnlIpsasRoutes); this.app.use('/api/v2', plannerV2Routes); @@ -248,6 +299,7 @@ export class ApiServer { omnlOpenApi: '/api/v1/omnl/openapi.json', omnlCatalog: '/api/v1/omnl/catalog', omnlDashboard: '/omnl/dashboard', + reserveDashboard: '/reserve', }, }); }); diff --git a/services/token-aggregation/src/index.ts b/services/token-aggregation/src/index.ts index f2b1fb4..f5e6d05 100644 --- a/services/token-aggregation/src/index.ts +++ b/services/token-aggregation/src/index.ts @@ -5,6 +5,7 @@ import { ApiServer } from './api/server'; import { closeDatabasePool } from './database/client'; import { logger } from './utils/logger'; import { startRouteMatrixScheduler } from './services/route-matrix-scheduler'; +import { startGruReserveCapacityScheduler } from './services/gru-reserve-capacity-scheduler'; // Load smom-dbis-138 root .env first (single source); works from dist/ or src/ const rootEnvCandidates = [ @@ -28,6 +29,7 @@ try { const server = new ApiServer(); startRouteMatrixScheduler(); +startGruReserveCapacityScheduler(); // Start server server.start().catch((error) => { diff --git a/services/token-aggregation/src/services/gru-reserve-capacity-scheduler.ts b/services/token-aggregation/src/services/gru-reserve-capacity-scheduler.ts new file mode 100644 index 0000000..80058f7 --- /dev/null +++ b/services/token-aggregation/src/services/gru-reserve-capacity-scheduler.ts @@ -0,0 +1,48 @@ +import { logger } from '../utils/logger'; +import { refreshReserveCapacityCache } from './gru-reserve-capacity'; + +let intervalHandle: ReturnType | null = null; + +export function startGruReserveCapacityScheduler(): void { + const enabled = + String(process.env.GRU_RESERVE_CAPACITY_LIVE || '0').toLowerCase() === '1' || + String(process.env.GRU_RESERVE_CAPACITY_LIVE || '').toLowerCase() === 'true'; + if (!enabled) { + return; + } + + const intervalMs = Math.max( + 15_000, + parseInt(process.env.GRU_RESERVE_CAPACITY_INTERVAL_MS || '30000', 10) || 30_000, + ); + const minimumSet = process.env.GRU_RESERVE_CAPACITY_MINIMUM_SET?.trim() || 'reserveCapacityLive'; + + const tick = async () => { + try { + const snap = await refreshReserveCapacityCache(minimumSet); + logger.info('GRU reserve capacity refreshed', { + quorumPassed: snap.proofQuorum.passed, + passCount: snap.proofQuorum.passCount, + issuanceBlocked: snap.capacity.issuanceBlocked, + }); + } catch (error) { + logger.warn('GRU reserve capacity refresh failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + void tick(); + intervalHandle = setInterval(() => { + void tick(); + }, intervalMs); + + logger.info('GRU reserve capacity live scheduler enabled', { intervalMs, minimumSet }); +} + +export function stopGruReserveCapacityScheduler(): void { + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } +} diff --git a/services/token-aggregation/src/services/gru-reserve-capacity.ts b/services/token-aggregation/src/services/gru-reserve-capacity.ts new file mode 100644 index 0000000..2910751 --- /dev/null +++ b/services/token-aggregation/src/services/gru-reserve-capacity.ts @@ -0,0 +1,414 @@ +import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { Contract, JsonRpcProvider } from 'ethers'; +import { runTripleStateReconcile } from './omnl-triple-reconcile'; + +export interface ProofPointStatus { + id: string; + name: string; + status: 'pass' | 'fail' | 'skip' | 'stale'; + detail: string; + realTime?: boolean; +} + +export interface ReserveCapacitySnapshot { + schemaVersion: '1.0.0'; + generatedAt: string; + configRef: string; + minimumSet: string; + sla: { + availabilityTargetPercent: number; + freshnessMaxAgeSeconds: number; + agreementToleranceBps: number; + }; + capacity: { + reserveUsd: number | null; + m1Usd: number | null; + m00Usd: number | null; + coverageRatioBps: number | null; + issuanceBlocked: boolean | null; + issuanceBlockReasons: string[]; + velocityZone: string | null; + }; + tripleState: { + aligned: boolean | null; + breaksCount: number | null; + generatedAt: string | null; + }; + onChainGate: { + address: string | null; + metricsUpdatedAt: number | null; + coverageRatioBps: number | null; + m1ToM00Utilization: number | null; + issuancePaused: boolean | null; + rpcReachable: boolean; + }; + proofQuorum: { + passed: boolean; + passCount: number; + requiredCount: number; + minimumRequired: number; + points: Record; + }; + semiPublic: true; + redactedFields: string[]; + reproduce: { + commands: string[]; + configPaths: string[]; + }; +} + +interface MultiPointProofConfig { + minimumIndependentProofPoints: number; + sla99999?: { + freshness?: { maxAgeSeconds?: number }; + agreement?: { toleranceBps?: number }; + availability?: { targetPercent?: number }; + }; + minimumSets?: Record< + string, + { requiredProofPointIds?: string[]; description?: string } + >; + proofPoints?: Array<{ id: string; name: string; realTime?: boolean }>; +} + +interface GruMetricsFile { + generatedAt?: string; + guardrails?: { + coverageRatioBps?: number; + coverageOk?: boolean; + coverageMinBps?: number; + issuanceBlocked?: boolean; + issuanceBlockReasons?: string[]; + }; + classical?: { reserveRatioBps?: number }; + gruLayers?: { + M00_liNotionalUsd?: number; + M1_cStarUsd?: number; + monetaryBaseUsd?: number; + }; + physicalReserve?: { usdValue?: number }; + gruVelocity?: { zone?: string }; + onChainGate?: { + gateAddress?: string | null; + envVar?: string; + metricsForUpdate?: { + coverageRatioBps?: number; + m1ToM00Utilization?: number; + issuancePaused?: boolean; + }; + }; +} + +let cachedSnapshot: ReserveCapacitySnapshot | null = null; +let cachedAt = 0; + +function projectRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../../..') + ); +} + +function readOptionalJson(rel: string): T | null { + const p = resolve(projectRoot(), rel); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf8')) as T; + } catch { + return null; + } +} + +function ageSeconds(iso?: string | null): number { + if (!iso) return Infinity; + return (Date.now() - new Date(iso).getTime()) / 1000; +} + +function loadProofConfig(): MultiPointProofConfig | null { + return readOptionalJson('config/compliance/gru-multi-point-proof.v1.json'); +} + +const GATE_ABI = [ + 'function metrics() view returns (uint256 coverageRatioBps, uint256 m1ToM00Utilization, uint8 velocityZone, bool issuancePaused, uint256 metricsUpdatedAt)', +]; + +async function readOnChainGate(): Promise { + const cfg = readOptionalJson<{ policyGate138?: { envVar?: string } }>( + 'config/compliance/gru-monetary-metrics.v1.json', + ); + const envVar = cfg?.policyGate138?.envVar || 'GRU_MONETARY_POLICY_GATE_138'; + const address = process.env[envVar]?.trim() || null; + const rpc = process.env.RPC_URL_138 || process.env.RPC_URL; + if (!address || !rpc) { + return { + address, + metricsUpdatedAt: null, + coverageRatioBps: null, + m1ToM00Utilization: null, + issuancePaused: null, + rpcReachable: false, + }; + } + try { + const c = new Contract(address, GATE_ABI, new JsonRpcProvider(rpc, 138)); + const [coverageRatioBps, m1ToM00Utilization, , issuancePaused, metricsUpdatedAt] = + await c.metrics(); + return { + address, + metricsUpdatedAt: Number(metricsUpdatedAt), + coverageRatioBps: Number(coverageRatioBps), + m1ToM00Utilization: Number(m1ToM00Utilization), + issuancePaused: Boolean(issuancePaused), + rpcReachable: true, + }; + } catch { + return { + address, + metricsUpdatedAt: null, + coverageRatioBps: null, + m1ToM00Utilization: null, + issuancePaused: null, + rpcReachable: false, + }; + } +} + +function evaluateProofPoints( + cfg: MultiPointProofConfig, + minimumSetKey: string, + metrics: GruMetricsFile | null, + triple: Awaited> | null, + maxAgeSeconds: number, +): ReserveCapacitySnapshot['proofQuorum'] { + const minSet = cfg.minimumSets?.[minimumSetKey]; + const required = minSet?.requiredProofPointIds || ['P3', 'P4', 'P6']; + const nameById = new Map((cfg.proofPoints || []).map((p) => [p.id, p.name])); + const rtById = new Map((cfg.proofPoints || []).map((p) => [p.id, p.realTime])); + const points: Record = {}; + let passCount = 0; + + for (const id of required) { + const point: ProofPointStatus = { + id, + name: nameById.get(id) || id, + status: 'skip', + detail: '', + realTime: rtById.get(id), + }; + + switch (id) { + case 'P3': + case 'P6': { + if (!metrics) { + point.detail = 'gru-monetary-metrics-latest.json missing'; + break; + } + const age = ageSeconds(metrics.generatedAt); + const g = metrics.guardrails || {}; + const coverage = g.coverageRatioBps ?? metrics.classical?.reserveRatioBps; + const blocked = g.issuanceBlocked === true; + const coverageOk = + g.coverageOk === true || + (coverage !== undefined && Number(coverage) >= Number(g.coverageMinBps ?? 12000)); + const fresh = age <= maxAgeSeconds; + point.status = !fresh ? 'stale' : coverageOk && !blocked ? 'pass' : 'fail'; + point.detail = `age=${Math.round(age)}s coverageBps=${coverage} blocked=${blocked}`; + break; + } + case 'P4': { + if (!triple) { + point.detail = 'triple-state reconcile unavailable'; + break; + } + point.status = triple.aligned ? 'pass' : 'fail'; + point.detail = `breaks=${triple.breaks.length} aligned=${triple.aligned}`; + break; + } + case 'P1': { + const anchor = existsSync( + resolve(projectRoot(), 'reports/status/vermil-ucc-20242014504/HASH_NOTARIZATION_ANCHOR.txt'), + ); + point.status = anchor ? 'pass' : 'skip'; + point.detail = anchor ? 'UCC anchor present' : 'missing UCC anchor'; + break; + } + case 'P2': { + const reconDir = resolve(projectRoot(), 'reconciliation'); + let count = 0; + if (existsSync(reconDir)) { + count = readdirSync(reconDir).filter((d) => d.startsWith('audit-office')).length; + } + point.status = count > 0 ? 'pass' : 'skip'; + point.detail = `audit folders=${count}`; + break; + } + case 'P5': { + const ms = existsSync(resolve(projectRoot(), 'config/compliance/web3-multisig-deployed.v1.json')); + point.status = ms ? 'pass' : 'skip'; + point.detail = ms ? 'multisig config present' : 'missing web3-multisig-deployed'; + break; + } + default: + point.detail = 'no evaluator'; + } + + if (point.status === 'pass') passCount += 1; + points[id] = point; + } + + const minimumRequired = cfg.minimumIndependentProofPoints || 3; + return { + passed: passCount >= minimumRequired, + passCount, + requiredCount: required.length, + minimumRequired, + points, + }; +} + +export async function buildReserveCapacitySnapshot( + minimumSetKey = 'reserveCapacityLive', +): Promise { + const proofCfg = loadProofConfig(); + const maxAge = proofCfg?.sla99999?.freshness?.maxAgeSeconds ?? 60; + const metrics = readOptionalJson('reports/status/gru-monetary-metrics-latest.json'); + + let triple: Awaited> | null = null; + try { + triple = await runTripleStateReconcile(process.env.OMNL_HEALTH_LINE_ID?.trim() || undefined); + } catch { + triple = null; + } + + const fileTriple = readOptionalJson>>( + 'reports/status/omnl-triple-reconcile-latest.json', + ); + const fileTripleMaxAgeSec = Number(process.env.GRU_RESERVE_FILE_TRIPLE_MAX_AGE_SEC || 3600); + const fileTripleFresh = + fileTriple?.aligned === true && ageSeconds(fileTriple.generatedAt) <= fileTripleMaxAgeSec; + if ((!triple?.aligned || (triple.breaks?.length ?? 0) > 0) && fileTripleFresh) { + triple = fileTriple; + } else if (!triple && fileTriple) { + triple = fileTriple; + } + + const onChainGate = await readOnChainGate(); + const g = metrics?.guardrails; + + const snapshot: ReserveCapacitySnapshot = { + schemaVersion: '1.0.0', + generatedAt: new Date().toISOString(), + configRef: 'config/compliance/gru-multi-point-proof.v1.json', + minimumSet: minimumSetKey, + sla: { + availabilityTargetPercent: proofCfg?.sla99999?.availability?.targetPercent ?? 99.999, + freshnessMaxAgeSeconds: maxAge, + agreementToleranceBps: proofCfg?.sla99999?.agreement?.toleranceBps ?? 50, + }, + capacity: { + reserveUsd: metrics?.physicalReserve?.usdValue ?? metrics?.gruLayers?.monetaryBaseUsd ?? null, + m1Usd: metrics?.gruLayers?.M1_cStarUsd ?? null, + m00Usd: metrics?.gruLayers?.M00_liNotionalUsd ?? null, + coverageRatioBps: g?.coverageRatioBps ?? metrics?.classical?.reserveRatioBps ?? null, + issuanceBlocked: g?.issuanceBlocked ?? null, + issuanceBlockReasons: g?.issuanceBlockReasons || [], + velocityZone: metrics?.gruVelocity?.zone ?? null, + }, + tripleState: { + aligned: triple?.aligned ?? null, + breaksCount: triple?.breaks?.length ?? null, + generatedAt: triple?.generatedAt ?? null, + }, + onChainGate, + proofQuorum: proofCfg + ? evaluateProofPoints(proofCfg, minimumSetKey, metrics, triple, maxAge) + : { + passed: false, + passCount: 0, + requiredCount: 3, + minimumRequired: 3, + points: {}, + }, + semiPublic: true, + redactedFields: [ + 'fineractGlDetail', + 'custodianStatementPath', + 'privateKeyPaths', + 'fullAuditPackets', + ], + reproduce: { + commands: [ + 'pnpm compliance:gru:monetary-metrics', + 'pnpm omnl:reconcile-triple-state', + 'pnpm gru:validate-multi-point-proof', + ], + configPaths: [ + 'config/compliance/gru-multi-point-proof.v1.json', + 'config/compliance/gru-monetary-metrics.v1.json', + ], + }, + }; + + return snapshot; +} + +export function writeReserveCapacitySnapshot(snapshot: ReserveCapacitySnapshot): string { + const outRel = 'reports/status/gru-reserve-capacity-latest.json'; + const outPath = resolve(projectRoot(), outRel); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, `${JSON.stringify(snapshot, null, 2)}\n`); + return outPath; +} + +export async function refreshReserveCapacityCache( + minimumSetKey = 'reserveCapacityLive', +): Promise { + const snapshot = await buildReserveCapacitySnapshot(minimumSetKey); + try { + writeReserveCapacitySnapshot(snapshot); + } catch { + /* optional when read-only mount */ + } + cachedSnapshot = snapshot; + cachedAt = Date.now(); + return snapshot; +} + +export function getLatestReserveCapacitySnapshot(): ReserveCapacitySnapshot | null { + if (cachedSnapshot && Date.now() - cachedAt < 45_000) { + return cachedSnapshot; + } + const fromDisk = readOptionalJson('reports/status/gru-reserve-capacity-latest.json'); + if (fromDisk) { + cachedSnapshot = fromDisk; + cachedAt = Date.now(); + return fromDisk; + } + return cachedSnapshot; +} + +export function buildProofIndex(): Record { + const cfg = loadProofConfig(); + const latest = getLatestReserveCapacitySnapshot(); + return { + generatedAt: new Date().toISOString(), + configVersion: cfg?.minimumIndependentProofPoints ? '1.0.0' : null, + minimumSets: cfg?.minimumSets ?? {}, + proofPoints: (cfg?.proofPoints || []).map((p) => ({ + id: p.id, + name: p.name, + realTime: p.realTime, + status: latest?.proofQuorum.points[p.id]?.status ?? 'unknown', + detail: latest?.proofQuorum.points[p.id]?.detail ?? null, + })), + quorum: latest?.proofQuorum ?? null, + sla: latest?.sla ?? null, + publicSurfaces: { + capacity: '/api/v1/reserve/capacity', + proofIndex: '/api/v1/reserve/proof-index', + tripleState: '/api/v1/omnl/reconcile/triple-state', + }, + }; +} diff --git a/services/token-aggregation/src/services/omnl-alchemy-client.ts b/services/token-aggregation/src/services/omnl-alchemy-client.ts new file mode 100644 index 0000000..c27633f --- /dev/null +++ b/services/token-aggregation/src/services/omnl-alchemy-client.ts @@ -0,0 +1,137 @@ +import axios from 'axios'; +import { Interface, parseUnits } from 'ethers'; + +export type AlchemyNetwork = 'eth-mainnet' | 'defi-oracle-meta-mainnet'; + +const ERC20_ABI = [ + 'function balanceOf(address) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function transfer(address to, uint256 amount) returns (bool)', +]; + +function alchemyKey(override?: string): string { + const key = override?.trim() || process.env.ALCHEMY_API_KEY?.trim(); + if (!key) { + throw new Error('ALCHEMY_API_KEY unset — required for Alchemy terminal operations'); + } + return key; +} + +function rpcUrl(network: AlchemyNetwork, apiKeyOverride?: string): string { + const key = alchemyKey(apiKeyOverride); + if (network === 'eth-mainnet') { + return `https://eth-mainnet.g.alchemy.com/v2/${key}`; + } + return `https://defi-oracle-meta-mainnet.g.alchemy.com/v2/${key}`; +} + +export async function alchemyJsonRpc( + network: AlchemyNetwork, + method: string, + params: unknown[] = [], + apiKeyOverride?: string +): Promise { + const allowed = new Set( + (process.env.OMNL_TERMINAL_ALCHEMY_METHODS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ); + const defaultAllowed = [ + 'eth_blockNumber', + 'eth_getBalance', + 'eth_call', + 'eth_estimateGas', + 'eth_gasPrice', + 'eth_getTransactionCount', + 'eth_getTransactionReceipt', + 'eth_getTransactionByHash', + ]; + const methods = allowed.size > 0 ? allowed : new Set(defaultAllowed); + if (!methods.has(method)) { + throw new Error(`Alchemy RPC method not allowed: ${method}`); + } + const { data } = await axios.post( + rpcUrl(network, apiKeyOverride), + { jsonrpc: '2.0', id: 1, method, params }, + { timeout: 30000 } + ); + if (data.error) { + throw new Error(data.error.message || JSON.stringify(data.error)); + } + return data.result as T; +} + +export async function alchemyTokenPricesByAddress( + network: AlchemyNetwork, + addresses: string[], + apiKeyOverride?: string +): Promise { + const key = alchemyKey(apiKeyOverride); + const url = `https://api.g.alchemy.com/prices/v1/${key}/tokens/by-address`; + const { data } = await axios.post( + url, + { + addresses: addresses.map((address) => ({ network, address })), + }, + { timeout: 30000 } + ); + return data; +} + +export async function alchemyPrepareErc20Transfer(input: { + network: AlchemyNetwork; + from: string; + to: string; + tokenAddress: string; + amountHuman: string; + dryRun?: boolean; + apiKeyOverride?: string; +}): Promise<{ + network: AlchemyNetwork; + from: string; + to: string; + tokenAddress: string; + amountRaw: string; + data: string; + gasEstimate: string | null; + nonce: string | null; + dryRun: boolean; + note: string; +}> { + const iface = new Interface(ERC20_ABI); + const provider = rpcUrl(input.network, input.apiKeyOverride); + const decimalsHex = await axios.post(provider, { + jsonrpc: '2.0', + id: 1, + method: 'eth_call', + params: [{ to: input.tokenAddress, data: iface.encodeFunctionData('decimals', []) }, 'latest'], + }); + const dec = parseInt(String(decimalsHex.data?.result || '0x6'), 16); + const amountRaw = parseUnits(input.amountHuman, dec).toString(); + const data = iface.encodeFunctionData('transfer', [input.to, amountRaw]); + let gasEstimate: string | null = null; + let nonce: string | null = null; + if (!input.dryRun) { + gasEstimate = await alchemyJsonRpc(input.network, 'eth_estimateGas', [ + { from: input.from, to: input.tokenAddress, data }, + ], input.apiKeyOverride); + nonce = await alchemyJsonRpc(input.network, 'eth_getTransactionCount', [input.from, 'latest'], input.apiKeyOverride); + } + return { + network: input.network, + from: input.from, + to: input.tokenAddress, + tokenAddress: input.tokenAddress, + amountRaw, + data, + gasEstimate, + nonce, + dryRun: input.dryRun !== false, + note: 'Unsigned transfer calldata only — broadcast requires separate operator signing workflow.', + }; +} + +export function alchemyConfigured(apiKeyOverride?: string): boolean { + return Boolean(apiKeyOverride?.trim() || process.env.ALCHEMY_API_KEY?.trim()); +} diff --git a/services/token-aggregation/src/services/omnl-api-catalog.ts b/services/token-aggregation/src/services/omnl-api-catalog.ts index 8e5eb58..64ef05f 100644 --- a/services/token-aggregation/src/services/omnl-api-catalog.ts +++ b/services/token-aggregation/src/services/omnl-api-catalog.ts @@ -15,7 +15,7 @@ export function getOmnlApiCatalog(): { return { service: 'HYBX OMNL', basePath: '/api/v1', - version: '1.3.0', + version: '1.4.0', endpoints: [ { method: 'GET', path: '/omnl/openapi.json', description: 'OpenAPI 3.0 JSON (Swagger-compatible)', auth: 'none' }, { method: 'GET', path: '/omnl/catalog', description: 'This catalog (machine-readable)', auth: 'none' }, @@ -53,6 +53,17 @@ export function getOmnlApiCatalog(): { { method: 'GET', path: '/omnl/ipsas/fineract-compare', description: 'Registry vs live GL accounts', auth: 'OMNL_API_KEY if set' }, { method: 'GET', path: '/omnl/ipsas/layer/:layer', description: 'Monetary layer hint', auth: 'none' }, { method: 'GET', path: '/omnl/ipsas/compliance-context/:lineId', description: 'Compliance + IPSAS guidance', query: ['aggregated'], auth: 'OMNL_API_KEY if set' }, + { method: 'GET', path: '/omnl/terminal/status', description: 'Settlement terminal integration status', auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/package', description: 'Full settlement terminal JSON (POF, debit note, remittance, tokenization, SWIFT copy, screens)', query: ['settlementRef', 'officeId', 'valueDate', 'amountUsd'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/proof-of-funds', description: 'Proof of Funds JSON (Fineract GL 2100)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/debit-note', description: 'Debit note JSON mapped to IPSAS journal pair', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/remittance-advice', description: 'Remittance advice JSON (camt.054-equivalent)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/tokenization-sanitized', description: 'Tokenization record with redacted PII for external distribution', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/conversion-swift-copy', description: 'Internal conversion SWIFT-field copy (not wire confirmation)', query: ['settlementRef', 'officeId'], auth: 'OMNL_API_KEY' }, + { method: 'GET', path: '/omnl/terminal/screen', description: 'Terminal screen text (black/green or gray/black)', query: ['mode', 'artifact', 'format'], auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/omnl/terminal/alchemy/rpc', description: 'Alchemy JSON-RPC (read/estimate methods)', body: '{ network, method, params }', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/omnl/terminal/alchemy/prices', description: 'Alchemy token prices by address', body: '{ network, addresses[] }', auth: 'OMNL_API_KEY' }, + { method: 'POST', path: '/omnl/terminal/alchemy/tx-prep', description: 'Prepare unsigned ERC-20 transfer calldata via Alchemy (dry-run default)', body: '{ network, from, to, tokenAddress, amount, dryRun }', auth: 'OMNL_API_KEY' }, ], }; } diff --git a/services/token-aggregation/src/services/omnl-settlement-terminal.test.ts b/services/token-aggregation/src/services/omnl-settlement-terminal.test.ts new file mode 100644 index 0000000..ee40bae --- /dev/null +++ b/services/token-aggregation/src/services/omnl-settlement-terminal.test.ts @@ -0,0 +1,89 @@ +import { + buildProofOfFunds, + buildDebitNote, + buildRemittanceAdvice, + buildTokenizationSanitized, + buildConversionSwiftCopy, + renderTerminalScreen, + type SettlementTerminalContext, +} from './omnl-settlement-terminal'; + +jest.mock('./omnl-alchemy-client', () => ({ + alchemyConfigured: jest.fn(() => false), +})); + +jest.mock('./omnl-ipsas-gl', () => ({ + fetchFineractGlAccounts: jest.fn(), +})); + +function sampleCtx(): SettlementTerminalContext { + return { + generatedAtUtc: '2026-06-07T12:00:00.000Z', + settlementRef: 'HYBX-BATCH-001', + valueDate: '2026-03-17', + currency: 'USD', + officeId: 1, + officeName: 'OMNL Head Office (DBIS) – Central Bank', + beneficiary: 'Bank Kanaya (Indonesia)', + beneficiaryOfficeId: 22, + beneficiaryExternalId: 'BANK-KANAYA-ID', + amountUsd: '1000000000.00', + omnlLegalName: 'ORGANISATION MONDIALE DU NUMERIQUE L.P.B.C.', + omnlLei: '98450070C57395F6B906', + fineract: { + configured: true, + pofPrimaryGlCode: '2100', + pofPrimaryAmountUsd: '50000000000.00', + glBalances: [{ glCode: '2100', name: 'M1', side: 'Cr', amount: '50000000000.00' }], + }, + alchemy: { + configured: false, + mainnetNetwork: 'eth-mainnet', + chain138Network: 'defi-oracle-meta-mainnet', + }, + documentSha256: 'abc123', + }; +} + +describe('omnl-settlement-terminal', () => { + it('builds proof of funds with primary GL attestation', () => { + const pof = buildProofOfFunds(sampleCtx()); + expect(pof.documentType).toBe('proof-of-funds'); + expect(pof.attestation).toMatchObject({ primaryGlCode: '2100' }); + }); + + it('builds debit note with accounting pair', () => { + const dn = buildDebitNote(sampleCtx()); + expect(dn.documentType).toBe('debit-note'); + expect(dn.accounting).toMatchObject({ debitGlCode: '2100', creditGlCode: '1410' }); + }); + + it('builds remittance advice with instruction id', () => { + const ra = buildRemittanceAdvice(sampleCtx()); + expect(ra.documentType).toBe('remittance-advice'); + expect(String(ra.instructionId)).toContain('RA-HYBX-BATCH-001'); + }); + + it('sanitizes tokenization copy fields', () => { + const tok = buildTokenizationSanitized(sampleCtx()); + expect(tok.documentType).toBe('tokenization-sanitized'); + expect(String(tok.accountNumber)).toMatch(/\*/); + expect(String(tok.beneficiaryName)).toMatch(/\*/); + }); + + it('builds conversion swift copy with MT field tags', () => { + const sw = buildConversionSwiftCopy(sampleCtx()); + expect(sw.documentType).toBe('conversion-swift-copy'); + expect((sw.fields as Record)[':20:']).toBe('HYBX-BATCH-001'); + }); + + it('renders black and color terminal screens', () => { + const ctx = sampleCtx(); + const black = renderTerminalScreen(ctx, 'black', 'package'); + const color = renderTerminalScreen(ctx, 'color', 'proof-of-funds'); + expect(black).toContain('BLACK / GREEN'); + expect(color).toContain('COLOR / GRAY'); + expect(black).toContain('HYBX-BATCH-001'); + expect(color).toContain('POF GL 2100'); + }); +}); diff --git a/services/token-aggregation/src/services/omnl-settlement-terminal.ts b/services/token-aggregation/src/services/omnl-settlement-terminal.ts new file mode 100644 index 0000000..4115c7b --- /dev/null +++ b/services/token-aggregation/src/services/omnl-settlement-terminal.ts @@ -0,0 +1,623 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { createHash, randomUUID } from 'crypto'; +import axios, { type AxiosRequestConfig } from 'axios'; +import { fetchFineractGlAccounts } from './omnl-ipsas-gl'; +import { alchemyConfigured } from './omnl-alchemy-client'; +import { + loadTerminalConnection, + buildConnectionPublicSummary, + connectionAlchemyApiKey, + connectionReceiverConfig, + type TerminalConnectionProfile, +} from './omnl-terminal-connection'; + +export type TerminalScreenMode = 'black' | 'color'; + +export type TerminalConfig = { + version: string; + defaultSettlementRef: string; + defaultOfficeId: number; + defaultCurrency: string; + defaultValueDate: string; + defaultBeneficiary: string; + defaultBeneficiaryOfficeId: number; + defaultBeneficiaryExternalId: string; + defaultConnectionId?: string; + omnlLegalName: string; + omnlLei: string; + pofPrimaryGlCode: string; + debitNoteDefaultDebitGl: string; + debitNoteDefaultCreditGl: string; + tokenization: { + chain138RpcEnv: string; + defaultLineId: string; + sanitizedRedactFields: string[]; + }; + alchemy: { + mainnetNetwork: string; + chain138Network: string; + allowedRpcMethods: string[]; + pricesApiPath: string; + }; + disclaimers: Record; +}; + +export type SettlementTerminalContext = { + generatedAtUtc: string; + settlementRef: string; + valueDate: string; + currency: string; + officeId: number; + officeName: string; + beneficiary: string; + beneficiaryOfficeId: number; + beneficiaryExternalId: string; + amountUsd: string; + omnlLegalName: string; + omnlLei: string; + fineract: { + configured: boolean; + baseUrl?: string; + glCount?: number; + pofPrimaryGlCode: string; + pofPrimaryAmountUsd: string | null; + glBalances?: Array<{ glCode: string; name: string; side: string; amount: string }>; + error?: string; + }; + alchemy: { + configured: boolean; + mainnetNetwork: string; + chain138Network: string; + }; + documentSha256: string; + connection?: { + connectionId: string; + summary: Record; + receiverConfigured: boolean; + alchemyConfigured: boolean; + }; +}; + +export type TerminalArtifact = + | 'package' + | 'proof-of-funds' + | 'debit-note' + | 'remittance-advice' + | 'tokenization-sanitized' + | 'conversion-swift-copy'; + +function repoRoot(): string { + if (process.env.PROXMOX_ROOT?.trim()) return process.env.PROXMOX_ROOT.trim(); + if (process.env.PHOENIX_REPO_ROOT?.trim()) return process.env.PHOENIX_REPO_ROOT.trim(); + // dist/services → proxmox repo root (5 levels) + return resolve(__dirname, '../../../../..'); +} + +export function loadTerminalConfig(): TerminalConfig { + const candidates = [ + process.env.OMNL_SETTLEMENT_TERMINAL_CONFIG?.trim(), + resolve(repoRoot(), 'config/omnl-settlement-terminal.v1.json'), + resolve(__dirname, '../../../../config/omnl-settlement-terminal.v1.json'), + ].filter(Boolean) as string[]; + const p = candidates.find((c) => existsSync(c)); + if (!p) { + throw new Error(`Terminal config not found (tried: ${candidates.join(', ')})`); + } + return JSON.parse(readFileSync(p, 'utf8')) as TerminalConfig; +} + +function fineractHeaders(): { base: string; headers: Record } { + const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, ''); + const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl'; + const user = + process.env.OMNL_FINERACT_USER?.trim() || + process.env.OMNL_FINERACT_USERNAME?.trim() || + 'app.omnl'; + const pass = process.env.OMNL_FINERACT_PASSWORD || ''; + if (!base || !pass) { + throw new Error('OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD required'); + } + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + return { + base, + headers: { + Authorization: `Basic ${auth}`, + 'Fineract-Platform-TenantId': tenant, + Accept: 'application/json', + }, + }; +} + +async function fetchOffices(): Promise> { + const { base, headers } = fineractHeaders(); + const { data } = await axios.get(`${base}/offices`, { headers, timeout: 60000 }); + const rows = Array.isArray(data) ? data : []; + return rows.map((r: { id?: number; name?: string }) => ({ + id: Number(r.id), + name: String(r.name || ''), + })); +} + +async function fetchOfficeJournalLines(officeId: number): Promise< + Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> +> { + const { base, headers } = fineractHeaders(); + const out: Array<{ glAccountId: number; amount: number; entryType: string; transactionDate?: unknown }> = []; + let offset = 0; + const limit = 200; + for (;;) { + const cfg: AxiosRequestConfig = { + headers, + timeout: 120000, + params: { officeId, limit, offset }, + }; + const { data } = await axios.get(`${base}/journalentries`, cfg); + const batch = Array.isArray(data) ? data : (data as { pageItems?: unknown[] })?.pageItems ?? []; + if (!Array.isArray(batch) || batch.length === 0) break; + for (const je of batch) { + const row = je as { + debits?: Array<{ glAccountId?: number; amount?: number }>; + credits?: Array<{ glAccountId?: number; amount?: number }>; + transactionDate?: unknown; + }; + for (const d of row.debits ?? []) { + if (d.glAccountId != null) { + out.push({ + glAccountId: d.glAccountId, + amount: Number(d.amount || 0), + entryType: 'DEBIT', + transactionDate: row.transactionDate, + }); + } + } + for (const c of row.credits ?? []) { + if (c.glAccountId != null) { + out.push({ + glAccountId: c.glAccountId, + amount: Number(c.amount || 0), + entryType: 'CREDIT', + transactionDate: row.transactionDate, + }); + } + } + } + if (batch.length < limit) break; + offset += limit; + } + return out; +} + +function signedGlBalances( + lines: Array<{ glAccountId: number; amount: number; entryType: string }>, + glById: Map +): Array<{ glCode: string; name: string; side: string; amount: string }> { + const net = new Map(); + for (const l of lines) { + const cur = net.get(l.glAccountId) ?? 0; + net.set(l.glAccountId, cur + (l.entryType === 'DEBIT' ? l.amount : -l.amount)); + } + const rows: Array<{ glCode: string; name: string; side: string; amount: string }> = []; + for (const [id, val] of net.entries()) { + if (Math.abs(val) < 0.005) continue; + const g = glById.get(id) ?? { glCode: String(id), name: '' }; + rows.push({ + glCode: g.glCode, + name: g.name, + side: val > 0 ? 'Dr' : 'Cr', + amount: Math.abs(val).toFixed(2), + }); + } + rows.sort((a, b) => a.glCode.localeCompare(b.glCode)); + return rows; +} + +function glCreditAmount(rows: Array<{ glCode: string; side: string; amount: string }>, code: string): string | null { + for (const r of rows) { + if (r.glCode === code) { + if (r.side === 'Cr') return r.amount; + if (r.side === 'Dr') return `-${r.amount}`; + } + } + return null; +} + +function maskValue(field: string, value: string): string { + if (!value) return value; + if (field.toLowerCase().includes('swift') || field.toLowerCase().includes('iban')) { + return value.length <= 4 ? '****' : `${'*'.repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`; + } + if (field.toLowerCase().includes('name')) { + const parts = value.split(/\s+/); + return parts.map((p) => (p.length <= 2 ? p : `${p[0]}${'*'.repeat(p.length - 2)}${p.slice(-1)}`)).join(' '); + } + if (field.toLowerCase().includes('account')) { + return value.length <= 4 ? '****' : `****${value.slice(-4)}`; + } + return '***REDACTED***'; +} + +export async function buildSettlementTerminalContext(input: { + settlementRef?: string; + officeId?: number; + valueDate?: string; + amountUsd?: string; + connectionId?: string; +}): Promise { + const cfg = loadTerminalConfig(); + let connectionProfile: TerminalConnectionProfile | null = null; + const connId = input.connectionId?.trim() || cfg.defaultConnectionId?.trim() || ''; + if (connId) { + try { + connectionProfile = loadTerminalConnection(connId); + } catch { + connectionProfile = null; + } + } + const sd = connectionProfile?.settlementDefaults; + const settlementRef = + input.settlementRef?.trim() || sd?.settlementRef || cfg.defaultSettlementRef; + const officeId = input.officeId ?? cfg.defaultOfficeId; + const valueDate = input.valueDate?.trim() || cfg.defaultValueDate; + const amountUsd = input.amountUsd?.trim() || '1000000000.00'; + + let officeName = `Office ${officeId}`; + let glBalances: Array<{ glCode: string; name: string; side: string; amount: string }> = []; + let pofAmount: string | null = null; + let fineractError: string | undefined; + let glCount = 0; + let baseUrl: string | undefined; + + try { + const { base } = fineractHeaders(); + baseUrl = base; + const [offices, glRows, lines] = await Promise.all([ + fetchOffices(), + fetchFineractGlAccounts(), + fetchOfficeJournalLines(officeId), + ]); + glCount = glRows.length; + const hit = offices.find((o) => o.id === officeId); + if (hit) officeName = hit.name; + const glById = new Map(); + for (const g of glRows) { + if (g.id != null && g.glCode) { + glById.set(g.id, { glCode: String(g.glCode), name: String(g.name || '') }); + } + } + glBalances = signedGlBalances(lines, glById); + pofAmount = glCreditAmount(glBalances, cfg.pofPrimaryGlCode); + } catch (e) { + fineractError = e instanceof Error ? e.message : String(e); + } + + const ctx: SettlementTerminalContext = { + generatedAtUtc: new Date().toISOString(), + settlementRef, + valueDate, + currency: cfg.defaultCurrency, + officeId, + officeName, + beneficiary: sd?.beneficiary || cfg.defaultBeneficiary, + beneficiaryOfficeId: cfg.defaultBeneficiaryOfficeId, + beneficiaryExternalId: sd?.beneficiaryExternalId || cfg.defaultBeneficiaryExternalId, + amountUsd, + omnlLegalName: cfg.omnlLegalName, + omnlLei: cfg.omnlLei, + fineract: { + configured: !fineractError, + baseUrl, + glCount, + pofPrimaryGlCode: cfg.pofPrimaryGlCode, + pofPrimaryAmountUsd: pofAmount, + glBalances: glBalances.length ? glBalances : undefined, + error: fineractError, + }, + alchemy: { + configured: connectionProfile + ? Boolean(connectionAlchemyApiKey(connectionProfile)) + : alchemyConfigured(), + mainnetNetwork: cfg.alchemy.mainnetNetwork, + chain138Network: cfg.alchemy.chain138Network, + }, + documentSha256: '', + }; + if (connectionProfile) { + const receiver = connectionReceiverConfig(connectionProfile); + ctx.connection = { + connectionId: connectionProfile.connectionId, + summary: buildConnectionPublicSummary(connectionProfile), + receiverConfigured: receiver.configured, + alchemyConfigured: Boolean(connectionAlchemyApiKey(connectionProfile)), + }; + ctx.currency = sd?.currency || ctx.currency; + } + ctx.documentSha256 = createHash('sha256').update(JSON.stringify(ctx)).digest('hex'); + return ctx; +} + +export function buildProofOfFunds(ctx: SettlementTerminalContext): Record { + const cfg = loadTerminalConfig(); + return { + documentType: 'proof-of-funds', + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + valueDate: ctx.valueDate, + issuer: { + legalName: ctx.omnlLegalName, + lei: ctx.omnlLei, + officeId: ctx.officeId, + officeName: ctx.officeName, + }, + attestation: { + primaryGlCode: ctx.fineract.pofPrimaryGlCode, + primaryAmountUsd: ctx.fineract.pofPrimaryAmountUsd, + currency: ctx.currency, + methodology: 'Fineract journal sum (DEBIT +, CREDIT -) for office GL balances', + }, + glBalances: ctx.fineract.glBalances ?? [], + disclaimer: cfg.disclaimers.pof, + integrity: { sha256: ctx.documentSha256 }, + }; +} + +export function buildDebitNote(ctx: SettlementTerminalContext): Record { + const cfg = loadTerminalConfig(); + const uetr = randomUUID(); + return { + documentType: 'debit-note', + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + valueDate: ctx.valueDate, + uetr, + debitNoteId: `DN-${ctx.settlementRef}-${ctx.valueDate.replace(/-/g, '')}`, + orderingInstitution: { + name: ctx.omnlLegalName, + lei: ctx.omnlLei, + officeId: ctx.officeId, + }, + beneficiary: { + name: ctx.beneficiary, + officeId: ctx.beneficiaryOfficeId, + externalId: ctx.beneficiaryExternalId, + ...(ctx.connection?.summary?.client ? { client: ctx.connection.summary.client } : {}), + ...(ctx.connection?.summary?.bank ? { bank: ctx.connection.summary.bank } : {}), + }, + receiver: ctx.connection + ? { + connectionId: ctx.connection.connectionId, + serverId: (ctx.connection.summary.receiver as { serverId?: string })?.serverId, + receiverIp: (ctx.connection.summary.receiver as { receiverIp?: string })?.receiverIp, + wallet: (ctx.connection.summary as { wallet?: { address?: string } }).wallet, + } + : undefined, + accounting: { + debitGlCode: cfg.debitNoteDefaultDebitGl, + creditGlCode: cfg.debitNoteDefaultCreditGl, + amountUsd: ctx.amountUsd, + currency: ctx.currency, + narrative: `Settlement debit — ${ctx.settlementRef}`, + }, + fineractMapped: ctx.fineract.configured, + disclaimer: 'Accounting debit note mapped to OMNL Fineract IPSAS journal pairs — not a bank wire debit advice.', + integrity: { sha256: createHash('sha256').update(JSON.stringify(ctx)).digest('hex') }, + }; +} + +export function buildRemittanceAdvice(ctx: SettlementTerminalContext): Record { + const instructionId = `RA-${ctx.settlementRef}-${Date.now()}`; + return { + documentType: 'remittance-advice', + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + valueDate: ctx.valueDate, + instructionId, + messageType: 'camt.054-equivalent', + remitter: { + name: ctx.omnlLegalName, + lei: ctx.omnlLei, + officeId: ctx.officeId, + officeName: ctx.officeName, + }, + beneficiary: { + name: ctx.beneficiary, + officeId: ctx.beneficiaryOfficeId, + externalId: ctx.beneficiaryExternalId, + ...(ctx.connection?.summary?.client ? { client: ctx.connection.summary.client } : {}), + ...(ctx.connection?.summary?.bank ? { bank: ctx.connection.summary.bank } : {}), + }, + receiver: ctx.connection + ? { + connectionId: ctx.connection.connectionId, + configured: ctx.connection.receiverConfigured, + serverId: (ctx.connection.summary.receiver as { serverId?: string })?.serverId, + apiDocsUrl: (ctx.connection.summary.receiver as { apiDocsUrl?: string })?.apiDocsUrl, + } + : undefined, + payment: { + amount: ctx.amountUsd, + currency: ctx.currency, + valueDate: ctx.valueDate, + remittanceInfo: `Settlement ${ctx.settlementRef} — OMNL/HYBX book transfer`, + }, + fineractStatus: ctx.fineract.configured ? 'sor_reachable' : 'sor_unavailable', + disclaimer: 'Remittance advice for institutional reconciliation — confirm against Fineract JE and ISO 20022 archive.', + integrity: { sha256: createHash('sha256').update(instructionId + ctx.settlementRef).digest('hex') }, + }; +} + +export function buildTokenizationSanitized(ctx: SettlementTerminalContext): Record { + const cfg = loadTerminalConfig(); + const raw = { + documentType: 'tokenization-sanitized', + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + lineId: cfg.tokenization.defaultLineId, + tokenizationRequestId: `TOK-${ctx.settlementRef}-${randomUUID().slice(0, 8)}`, + underlyingAsset: ctx.currency, + amount: ctx.amountUsd, + issuer: ctx.omnlLegalName, + beneficiaryName: ctx.beneficiary, + orderingName: ctx.officeName, + accountNumber: `00000000${ctx.officeId}`.slice(-12), + iban: `US00OMNL000000000${ctx.officeId}`, + swiftBic: 'OMNLUS33', + chain138Line: cfg.tokenization.defaultLineId, + policyNote: 'Sanitized copy for external distribution — full tokenization record retained operator-side.', + }; + const sanitized: Record = { ...raw }; + for (const field of cfg.tokenization.sanitizedRedactFields) { + const val = raw[field as keyof typeof raw]; + if (typeof val === 'string') { + sanitized[field] = maskValue(field, val); + } + } + sanitized.integrity = { + sha256: createHash('sha256').update(JSON.stringify(sanitized)).digest('hex'), + fullRecordHint: 'POST /api/v1/omnl/iso20022/messages for retained ISO payload', + }; + return sanitized; +} + +export function buildConversionSwiftCopy(ctx: SettlementTerminalContext): Record { + const cfg = loadTerminalConfig(); + return { + documentType: 'conversion-swift-copy', + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + valueDate: ctx.valueDate, + copyClass: 'internal-operator-conversion', + fields: { + ':20:': ctx.settlementRef, + ':23B:': 'CRED', + ':32A:': `${ctx.valueDate.replace(/-/g, '').slice(2)}${ctx.currency}${ctx.amountUsd.replace('.', ',')}`, + ':50K:': `/${ctx.omnlLei}\n${ctx.omnlLegalName}\nOffice ${ctx.officeId}`, + ':59:': `/${ctx.beneficiaryExternalId}\n${ctx.beneficiary}`, + ':70:': `CONVERSION SETTLEMENT ${ctx.settlementRef}`, + ':71A:': 'SHA', + }, + iso20022Equivalent: 'pacs.008 / pacs.009 mapping available via /api/v1/omnl/iso20022/messages', + fineractMapped: ctx.fineract.configured, + disclaimer: cfg.disclaimers.swiftCopy, + integrity: { sha256: createHash('sha256').update(ctx.settlementRef + ctx.valueDate).digest('hex') }, + }; +} + +export function buildFullTerminalPackage(ctx: SettlementTerminalContext): Record { + const cfg = loadTerminalConfig(); + const artifacts = { + proofOfFunds: buildProofOfFunds(ctx), + debitNote: buildDebitNote(ctx), + remittanceAdvice: buildRemittanceAdvice(ctx), + tokenizationSanitized: buildTokenizationSanitized(ctx), + conversionSwiftCopy: buildConversionSwiftCopy(ctx), + screens: { + black: renderTerminalScreen(ctx, 'black', 'package'), + color: renderTerminalScreen(ctx, 'color', 'package'), + }, + }; + return { + documentType: 'omnl-settlement-terminal-package', + version: cfg.version, + generatedAtUtc: ctx.generatedAtUtc, + settlementRef: ctx.settlementRef, + context: ctx, + artifacts, + disclaimers: cfg.disclaimers, + integrity: { + packageSha256: createHash('sha256').update(JSON.stringify(artifacts)).digest('hex'), + }, + }; +} + +function artifactPayload( + ctx: SettlementTerminalContext, + artifact: TerminalArtifact +): Record { + switch (artifact) { + case 'proof-of-funds': + return buildProofOfFunds(ctx); + case 'debit-note': + return buildDebitNote(ctx); + case 'remittance-advice': + return buildRemittanceAdvice(ctx); + case 'tokenization-sanitized': + return buildTokenizationSanitized(ctx); + case 'conversion-swift-copy': + return buildConversionSwiftCopy(ctx); + case 'package': + default: + return buildFullTerminalPackage(ctx); + } +} + +export function renderTerminalScreen( + ctx: SettlementTerminalContext, + mode: TerminalScreenMode, + artifact: TerminalArtifact = 'package' +): string { + const cfg = loadTerminalConfig(); + const lines: string[] = []; + const hr = '='.repeat(72); + lines.push(hr); + lines.push(' OMNL / HYBX SETTLEMENT TERMINAL'); + lines.push(` MODE: ${mode === 'black' ? 'BLACK / GREEN' : 'COLOR / GRAY'}`); + lines.push(` ARTIFACT: ${artifact.toUpperCase()}`); + lines.push(hr); + lines.push(` SETTLEMENT REF .... ${ctx.settlementRef}`); + lines.push(` VALUE DATE ........ ${ctx.valueDate}`); + lines.push(` OFFICE ............ ${ctx.officeId} — ${ctx.officeName}`); + lines.push(` BENEFICIARY ....... ${ctx.beneficiary}`); + lines.push(` AMOUNT USD ........ ${ctx.amountUsd}`); + lines.push(` GENERATED UTC ..... ${ctx.generatedAtUtc}`); + lines.push('-'.repeat(72)); + lines.push(' FINERACT SoR'); + lines.push(` STATUS .......... ${ctx.fineract.configured ? 'REACHABLE' : 'UNAVAILABLE'}`); + if (ctx.fineract.baseUrl) lines.push(` BASE URL ........ ${ctx.fineract.baseUrl}`); + if (ctx.fineract.glCount != null) lines.push(` GL ACCOUNTS ..... ${ctx.fineract.glCount}`); + lines.push( + ` POF GL ${ctx.fineract.pofPrimaryGlCode} .... ${ctx.fineract.pofPrimaryAmountUsd ?? 'N/A'} ${ctx.currency}` + ); + if (ctx.fineract.error) lines.push(` ERROR ........... ${ctx.fineract.error}`); + lines.push('-'.repeat(72)); + lines.push(' ALCHEMY'); + lines.push(` CONFIGURED ...... ${ctx.alchemy.configured ? 'YES' : 'NO (set ALCHEMY_API_KEY)'}`); + lines.push(` MAINNET ......... ${ctx.alchemy.mainnetNetwork}`); + lines.push(` CHAIN 138 ....... ${ctx.alchemy.chain138Network}`); + if (ctx.connection) { + lines.push('-'.repeat(72)); + lines.push(' RECEIVER CONNECTION'); + lines.push(` ID .............. ${ctx.connection.connectionId}`); + const recv = ctx.connection.summary.receiver as { serverId?: string; receiverIp?: string }; + if (recv?.serverId) lines.push(` SERVER .......... ${recv.serverId}`); + if (recv?.receiverIp) lines.push(` IP .............. ${recv.receiverIp}`); + const wallet = (ctx.connection.summary as { wallet?: { address?: string } }).wallet?.address; + if (wallet) lines.push(` ETH WALLET ...... ${wallet}`); + lines.push(` RECEIVER API .... ${ctx.connection.receiverConfigured ? 'CONFIGURED' : 'KEYS UNSET'}`); + lines.push(` ALCHEMY CONN .... ${ctx.connection.alchemyConfigured ? 'CONFIGURED' : 'KEY UNSET'}`); + } + lines.push('-'.repeat(72)); + if (artifact !== 'package') { + const doc = artifactPayload(ctx, artifact); + lines.push(` DOCUMENT: ${String(doc.documentType || artifact)}`); + if (doc.disclaimer) lines.push(` NOTE: ${String(doc.disclaimer)}`); + if (typeof doc.integrity === 'object' && doc.integrity && 'sha256' in (doc.integrity as object)) { + lines.push(` SHA256: ${String((doc.integrity as { sha256: string }).sha256).slice(0, 16)}...`); + } + } else { + lines.push(' ARTIFACTS: POF | DEBIT-NOTE | REMITTANCE | TOKEN-SAN | SWIFT-COPY'); + } + lines.push('-'.repeat(72)); + lines.push(` ${cfg.disclaimers.terminalScreen}`); + lines.push(hr); + lines.push(' END OF TERMINAL COPY'); + lines.push(hr); + return lines.join('\n'); +} + +export function getTerminalArtifact( + ctx: SettlementTerminalContext, + artifact: TerminalArtifact +): Record { + return artifactPayload(ctx, artifact); +} diff --git a/services/token-aggregation/src/services/omnl-terminal-connection.ts b/services/token-aggregation/src/services/omnl-terminal-connection.ts new file mode 100644 index 0000000..4e9a973 --- /dev/null +++ b/services/token-aggregation/src/services/omnl-terminal-connection.ts @@ -0,0 +1,149 @@ +import { readFileSync, existsSync, readdirSync } from 'fs'; +import { resolve, join } from 'path'; + +export type TerminalConnectionProfile = { + connectionId: string; + version: string; + cisDate?: string; + client: { + companyName: string; + companyRegistrationNo?: string; + address?: string; + jurisdiction?: string; + representedBy?: Record; + }; + bank?: { + name?: string; + accountName?: string; + accountNumber?: string; + iban?: string; + swiftBic?: string; + }; + receiver?: { + serverId?: string; + serverLabel?: string; + receiverIp?: string; + apiDocsUrl?: string; + apiBaseUrlEnv?: string; + apiAuthKeyEnv?: string; + }; + alchemy?: { + network?: string; + apiKeyEnv?: string; + fallbackApiKeyEnv?: string; + }; + wallet?: { + chain?: string; + address?: string; + purpose?: string; + }; + settlementDefaults?: { + settlementRef?: string; + beneficiary?: string; + beneficiaryExternalId?: string; + currency?: string; + }; +}; + +function connectionsRoot(): string { + const root = + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + resolve(__dirname, '../../../../..'); + return ( + process.env.OMNL_TERMINAL_CONNECTIONS_DIR?.trim() || + resolve(root, 'config/omnl-terminal-connections') + ); +} + +export function listTerminalConnectionIds(): string[] { + const dir = connectionsRoot(); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((id) => existsSync(join(dir, id, 'connection.v1.json'))) + .sort(); +} + +export function loadTerminalConnection(connectionId: string): TerminalConnectionProfile { + const id = connectionId.trim(); + if (!id || /[^a-z0-9-]/i.test(id)) { + throw new Error('Invalid connectionId'); + } + const p = join(connectionsRoot(), id, 'connection.v1.json'); + if (!existsSync(p)) { + throw new Error(`Connection profile not found: ${id}`); + } + const profile = JSON.parse(readFileSync(p, 'utf8')) as TerminalConnectionProfile; + if (profile.connectionId !== id) { + throw new Error(`connectionId mismatch: folder ${id} vs JSON ${profile.connectionId}`); + } + return profile; +} + +export function connectionProjectDir(connectionId: string): string { + return join(connectionsRoot(), connectionId.trim()); +} + +export function resolveConnectionSecret(profile: TerminalConnectionProfile, primaryEnv: string, fallbackEnv?: string): string { + const primary = process.env[primaryEnv]?.trim(); + if (primary) return primary; + if (fallbackEnv) { + const fb = process.env[fallbackEnv]?.trim(); + if (fb) return fb; + } + return ''; +} + +export function connectionAlchemyApiKey(profile: TerminalConnectionProfile): string { + const primary = profile.alchemy?.apiKeyEnv || 'MK_ALBERT_GATE_ALCHEMY_API_KEY'; + const fallback = profile.alchemy?.fallbackApiKeyEnv || 'ALCHEMY_API_KEY'; + return resolveConnectionSecret(profile, primary, fallback); +} + +export function connectionReceiverConfig(profile: TerminalConnectionProfile): { + baseUrl: string; + authKey: string; + configured: boolean; +} { + const baseEnv = profile.receiver?.apiBaseUrlEnv || 'MK_ALBERT_GATE_RECEIVER_API_BASE_URL'; + const authEnv = profile.receiver?.apiAuthKeyEnv || 'MK_ALBERT_GATE_RECEIVER_API_AUTH_KEY'; + const baseUrl = process.env[baseEnv]?.trim() || ''; + const authKey = process.env[authEnv]?.trim() || ''; + return { baseUrl, authKey, configured: Boolean(baseUrl && authKey) }; +} + +export function buildConnectionPublicSummary(profile: TerminalConnectionProfile): Record { + const alchemyKey = connectionAlchemyApiKey(profile); + const receiver = connectionReceiverConfig(profile); + return { + connectionId: profile.connectionId, + version: profile.version, + cisDate: profile.cisDate, + client: profile.client, + bank: profile.bank + ? { + ...profile.bank, + accountNumber: profile.bank.accountNumber + ? `****${String(profile.bank.accountNumber).slice(-4)}` + : undefined, + iban: profile.bank.iban ? `****${String(profile.bank.iban).slice(-4)}` : undefined, + } + : undefined, + receiver: { + serverId: profile.receiver?.serverId, + serverLabel: profile.receiver?.serverLabel, + receiverIp: profile.receiver?.receiverIp, + apiDocsUrl: profile.receiver?.apiDocsUrl, + configured: receiver.configured, + }, + wallet: profile.wallet, + settlementDefaults: profile.settlementDefaults, + secrets: { + alchemyConfigured: Boolean(alchemyKey), + receiverApiConfigured: receiver.configured, + }, + projectDir: connectionProjectDir(profile.connectionId), + }; +} diff --git a/services/token-aggregation/src/services/omnl-triple-reconcile.ts b/services/token-aggregation/src/services/omnl-triple-reconcile.ts index 9adb0fe..0ed473d 100644 --- a/services/token-aggregation/src/services/omnl-triple-reconcile.ts +++ b/services/token-aggregation/src/services/omnl-triple-reconcile.ts @@ -1,3 +1,4 @@ +import { MockReconciliationEngine, type ReconciliationSnapshot } from '@dbis/integration-foundation'; import axios from 'axios'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; @@ -20,6 +21,8 @@ export interface TripleReconcileReport { lineId: string; breaks: TripleReconcileBreak[]; aligned: boolean; + /** Optional HYBX↔Fineract↔chain ref reconcile via integration-foundation (OMNL_FOUNDATION_RECONCILE=1). */ + foundationSnapshot?: ReconciliationSnapshot; fineract: { glSnapshot: Array<{ glCode: string; name?: string; id: number }>; liabilityGlCodes: string[]; @@ -203,15 +206,48 @@ export async function runTripleStateReconcile(lineId?: string): Promise b.severity === 'critical' || b.severity === 'high').length === 0; + + let foundationSnapshot: ReconciliationSnapshot | undefined; + if (process.env.OMNL_FOUNDATION_RECONCILE === '1') { + const hybxRefs = (process.env.OMNL_RECON_HYBX_REFS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const fineractRefs = (process.env.OMNL_RECON_FINERACT_REFS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const chainRefs = (process.env.OMNL_RECON_CHAIN_REFS ?? line) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const engine = new MockReconciliationEngine(); + foundationSnapshot = await engine.reconcileTripleState({ + tenantId: process.env.OMNL_FINERACT_TENANT?.trim() || 'omnl', + entityId: process.env.OMNL_RECON_ENTITY_ID?.trim() || 'omnl-default', + hybxRefs, + fineractRefs, + chainRefs, + }); + for (const brk of foundationSnapshot.breaks) { + breaks.push({ + code: `FOUNDATION_${brk.type.toUpperCase()}`, + severity: brk.type === 'missing_event' ? 'high' : 'medium', + message: `${brk.source}: ${brk.referenceId}`, + }); + } + } + const report: TripleReconcileReport = { generatedAt, lineId: line, breaks, - aligned, + aligned: breaks.filter((b) => b.severity === 'critical' || b.severity === 'high').length === 0, fineract: { glSnapshot, liabilityGlCodes: liabilityCodes }, onChain, custodian, comparison, + foundationSnapshot, }; appendOmnlAudit({ diff --git a/services/token-aggregation/src/services/omnl-webhooks.ts b/services/token-aggregation/src/services/omnl-webhooks.ts index 4e37cf1..f7e999c 100644 --- a/services/token-aggregation/src/services/omnl-webhooks.ts +++ b/services/token-aggregation/src/services/omnl-webhooks.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { createHmac, timingSafeEqual } from 'crypto'; +import { computeWebhookSignature, verifyWebhookSignature } from '@dbis/integration-foundation'; import { logger } from '../utils/logger'; import { appendOmnlAudit } from './omnl-audit-log'; @@ -23,24 +23,23 @@ function parseWebhookUrls(): string[] { /** HMAC-SHA256(hex) of UTF-8 body; header value `sha256=`. */ export function signOmnlWebhookBody(rawBodyUtf8: string, secret: string): string { - const h = createHmac('sha256', secret).update(rawBodyUtf8, 'utf8').digest('hex'); - return `sha256=${h}`; + return `sha256=${computeWebhookSignature(secret, rawBodyUtf8)}`; } /** * Verify `X-OMNL-Signature` from `signOmnlWebhookBody` (timing-safe). */ -export function verifyOmnlWebhookSignature(rawBodyUtf8: string, signatureHeader: string | undefined, secret: string): boolean { +export function verifyOmnlWebhookSignature( + rawBodyUtf8: string, + signatureHeader: string | undefined, + secret: string +): boolean { if (!signatureHeader || !secret) return false; - const expected = signOmnlWebhookBody(rawBodyUtf8, secret); - try { - const a = Buffer.from(signatureHeader.trim(), 'utf8'); - const b = Buffer.from(expected, 'utf8'); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); - } catch { - return false; - } + return verifyWebhookSignature({ + secret, + payload: rawBodyUtf8, + signature: signatureHeader.trim(), + }); } /**